atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Add resident permissioned spaces viewer

zzstoatzz (Jun 9, 2026, 1:27 PM -0500) cf7de7d9 7924b727

+197
+5
src/http/router.zig
··· 8 8 api_docs, 9 9 api_openapi, 10 10 stats_page, 11 + private_spaces_page, 11 12 favicon, 12 13 og_image, 13 14 health, ··· 110 111 .{ .route = .api_openapi, .method = "HEAD", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI export probe without a response body." }, 111 112 .{ .route = .stats_page, .method = "GET", .path = "/stats", .group = "zds", .auth = "public", .summary = "Operational health and route latency page." }, 112 113 .{ .route = .stats_page, .method = "HEAD", .path = "/stats", .group = "zds", .auth = "public", .summary = "Stats page probe without a response body." }, 114 + .{ .route = .private_spaces_page, .method = "GET", .path = "/spaces", .group = "zds", .auth = "resident", .summary = "Resident view for permissioned-space records." }, 115 + .{ .route = .private_spaces_page, .method = "HEAD", .path = "/spaces", .group = "zds", .auth = "resident", .summary = "Permissioned-space record view probe." }, 113 116 .{ .route = .root, .method = "GET", .path = "/", .group = "zds", .auth = "public", .summary = "Public landing page for the PDS." }, 114 117 .{ .route = .root, .method = "HEAD", .path = "/", .group = "zds", .auth = "public", .summary = "Landing page probe." }, 115 118 .{ .route = .favicon, .method = "GET", .path = "/favicon.svg", .group = "zds", .auth = "public", .summary = "SVG favicon." }, ··· 263 266 try std.testing.expectEqual(Route.api_openapi, route(.HEAD, "/api/openapi.json")); 264 267 try std.testing.expectEqual(Route.stats_page, route(.GET, "/stats")); 265 268 try std.testing.expectEqual(Route.stats_page, route(.HEAD, "/stats")); 269 + try std.testing.expectEqual(Route.private_spaces_page, route(.GET, "/spaces")); 270 + try std.testing.expectEqual(Route.private_spaces_page, route(.HEAD, "/spaces")); 266 271 try std.testing.expectEqual(Route.favicon, route(.GET, "/favicon.svg")); 267 272 try std.testing.expectEqual(Route.favicon, route(.HEAD, "/favicon.svg")); 268 273 try std.testing.expectEqual(Route.og_image, route(.GET, "/og-image"));
+2
src/http/server.zig
··· 13 13 const http_api = @import("api.zig"); 14 14 const docs = @import("docs.zig"); 15 15 const passkeys = @import("../internal/passkeys.zig"); 16 + const private_spaces = @import("../internal/private_spaces.zig"); 16 17 const landing = @import("landing/mod.zig"); 17 18 const router = @import("router.zig"); 18 19 const stats = @import("stats.zig"); ··· 76 77 .api_docs => try docs.serve(request), 77 78 .api_openapi => try docs.serveOpenApi(request), 78 79 .stats_page => try stats.serve(request), 80 + .private_spaces_page => try private_spaces.page(request), 79 81 .favicon => try landing.serveFavicon(request), 80 82 .og_image => try landing.serveOgImage(request), 81 83 .health => try health(request),
+190
src/internal/private_spaces.zig
··· 1 + const std = @import("std"); 2 + const http_api = @import("../http/api.zig"); 3 + 4 + const http = std.http; 5 + 6 + pub fn page(request: *http_api.Request) !void { 7 + try http_api.respond(request, .ok, html, &html_headers); 8 + } 9 + 10 + const html_headers = [_]http.Header{ 11 + .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 12 + .{ .name = "cache-control", .value = "no-store" }, 13 + .{ .name = "access-control-allow-origin", .value = "*" }, 14 + .{ .name = "access-control-allow-private-network", .value = "true" }, 15 + .{ .name = "connection", .value = "close" }, 16 + }; 17 + 18 + const html = 19 + \\<!doctype html> 20 + \\<html lang="en"> 21 + \\<head> 22 + \\<meta charset="utf-8"> 23 + \\<meta name="viewport" content="width=device-width, initial-scale=1"> 24 + \\<title>zds spaces</title> 25 + \\<meta name="description" content="View permissioned-space records hosted on this PDS."> 26 + \\<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 27 + \\<style> 28 + \\:root{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#263326;--text:#f1f2ec;--muted:#a7a299;--accent:#8fb0ff;--green:#37c978;--field:#101410;--bad:#ffb4a8;--warn:#f4d35e} 29 + \\@media (prefers-color-scheme:light){:root{--bg:#f6f4ed;--panel:#fffdf7;--line:#d8d0c0;--text:#161412;--muted:#625c53;--accent:#315dcb;--green:#1b7340;--field:#fffaf1;--bad:#9f1d1d;--warn:#735f00;color-scheme:light}} 30 + \\*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 18% 0,color-mix(in srgb,var(--green) 20%,transparent),transparent 34%),var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace} 31 + \\.shell{width:min(100%,780px);margin:0 auto;padding:28px 16px 42px}a{color:inherit}.brand{display:inline-flex;margin-bottom:58px;text-decoration:none;font-weight:800}h1{margin:0;font-size:clamp(46px,16vw,96px);line-height:.88;letter-spacing:0}p{color:var(--muted);margin:14px 0 0;max-width:60ch}strong{color:var(--text)} 32 + \\.panel{margin-top:30px;border:1px solid var(--line);border-radius:10px;background:linear-gradient(135deg,color-mix(in srgb,var(--green) 10%,transparent),transparent 46%),linear-gradient(315deg,color-mix(in srgb,var(--accent) 8%,transparent),transparent 44%),var(--panel);padding:18px} 33 + \\label{display:block;margin:15px 0 6px;font-weight:760}input,select{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:9px;padding:12px}button{border:1px solid color-mix(in srgb,var(--accent) 60%,var(--line));border-radius:10px;background:var(--accent);color:#061021;font:inherit;font-weight:800;padding:12px;cursor:pointer}button.secondary{background:transparent;color:var(--text);border-color:var(--line)}button:disabled{opacity:.58;cursor:not-allowed} 34 + \\.status{min-height:1.5em;color:var(--muted)}.error{color:var(--bad)}.ok{color:var(--green)}.warn{color:var(--warn)}.hint{font-size:.92rem;color:var(--muted);margin:8px 0 0}.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end}.row button{margin-top:0}.resident{display:none}.resident.on{display:block}.login.off{display:none} 35 + \\.summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:16px}.metric{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 74%,transparent);padding:11px}.metric span{display:block;color:var(--muted);font-size:.78rem}.metric strong{display:block;margin-top:4px;font-size:1.35rem} 36 + \\.tabs{display:flex;gap:8px;overflow-x:auto;margin-top:18px;padding-bottom:2px}.tab{width:auto;margin:0;text-align:left;background:transparent;color:var(--muted);border-color:var(--line);white-space:nowrap}.tab[aria-selected="true"]{color:#061021;background:var(--accent);border-color:var(--accent)} 37 + \\.section{border-top:1px solid var(--line);padding-top:16px;margin-top:18px}.section h2{font-size:1rem;margin:0 0 8px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.actions button{width:auto}.list{display:grid;gap:8px;margin-top:12px}.item{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:11px;text-align:left;color:var(--text);width:100%}.item[aria-selected="true"]{border-color:color-mix(in srgb,var(--accent) 72%,var(--line));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 17%,transparent),color-mix(in srgb,var(--field) 78%,transparent))}.item-title{font-weight:800;overflow-wrap:anywhere}.item-meta{display:block;color:var(--muted);font-size:.82rem;overflow-wrap:anywhere}.pill{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:2px 7px;font-size:.78rem;color:var(--muted);margin-left:6px}.empty{border:1px dashed var(--line);border-radius:9px;padding:14px;color:var(--muted);margin-top:10px} 38 + \\pre{margin:12px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,#000 24%,var(--field));padding:12px;color:var(--text);max-height:56vh;overflow:auto}.record-head{display:flex;gap:10px;align-items:flex-start;justify-content:space-between;margin-top:14px}.record-head button{width:auto}.blob-chip{display:inline-flex;margin-top:8px;margin-right:6px;border:1px solid var(--line);border-radius:999px;padding:4px 8px;color:var(--accent);overflow-wrap:anywhere} 39 + \\@media (max-width:680px){.row,.grid,.summary{grid-template-columns:1fr}.actions button{width:100%}.record-head{display:block}.record-head button{width:100%;margin-top:8px}} 40 + \\</style> 41 + \\</head> 42 + \\<body> 43 + \\<main class="shell"> 44 + \\<a class="brand" href="/">zds</a> 45 + \\<h1>spaces</h1> 46 + \\<p>View private records in permissioned spaces for an account hosted on this PDS.</p> 47 + \\<section class="panel"> 48 + \\<form id="login-form" class="login"> 49 + \\<label for="identifier">account</label> 50 + \\<input id="identifier" name="identifier" autocomplete="username" required> 51 + \\<label for="password">account password or app password</label> 52 + \\<input id="password" name="password" type="password" autocomplete="current-password" required> 53 + \\<button style="width:100%;margin-top:22px">sign in</button> 54 + \\</form> 55 + \\<p id="status" class="status"></p> 56 + \\<section id="resident" class="resident" aria-live="polite"> 57 + \\<p id="account-label"></p> 58 + \\<div class="summary"> 59 + \\<div class="metric"><span>spaces</span><strong id="space-count">0</strong></div> 60 + \\<div class="metric"><span>records loaded</span><strong id="record-count">0</strong></div> 61 + \\</div> 62 + \\<div id="spaces" class="tabs" role="tablist" aria-label="permissioned spaces"></div> 63 + \\<section class="section"> 64 + \\<h2>record set</h2> 65 + \\<div class="grid"> 66 + \\<div> 67 + \\<label for="repo">member repo</label> 68 + \\<select id="repo"></select> 69 + \\</div> 70 + \\<div> 71 + \\<label for="collection">collection</label> 72 + \\<input id="collection" placeholder="fm.plyr.stg.track" spellcheck="false"> 73 + \\</div> 74 + \\</div> 75 + \\<div class="actions"> 76 + \\<button id="load-records" type="button">load records</button> 77 + \\<button id="next-records" class="secondary" type="button" disabled>next page</button> 78 + \\</div> 79 + \\<div id="records" class="list"></div> 80 + \\</section> 81 + \\<section class="section"> 82 + \\<h2>selected record</h2> 83 + \\<div id="record-detail" class="empty">Choose a record.</div> 84 + \\</section> 85 + \\</section> 86 + \\</section> 87 + \\</main> 88 + \\<script> 89 + \\let token=null, accountDid=null, accountHandle=null, selectedSpace=null, cursor=null; 90 + \\const $=(id)=>document.getElementById(id); 91 + \\const status=$('status'); 92 + \\function setStatus(text, cls=''){status.className='status '+cls;status.textContent=text} 93 + \\function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))} 94 + \\function spaceParts(uri){const parts=String(uri).split('/');return {type:parts[3]||uri,skey:parts[4]||''}} 95 + \\async function xrpc(path, params={}, options={}){ 96 + \\ const qs=new URLSearchParams(params); const url=path+(qs.size?'?'+qs:''); 97 + \\ const headers=Object.assign({}, options.headers||{}); if(token) headers.authorization='Bearer '+token; 98 + \\ const res=await fetch(url, Object.assign({}, options, {headers})); 99 + \\ const text=await res.text(); let body={}; try{body=text?JSON.parse(text):{}}catch{body={raw:text}} 100 + \\ if(!res.ok){const msg=body.error_description||body.message||body.error||text||res.statusText; throw new Error(msg)} 101 + \\ return body; 102 + \\} 103 + \\function renderSpaces(spaces){ 104 + \\ $('space-count').textContent=spaces.length; 105 + \\ const box=$('spaces'); box.innerHTML=''; 106 + \\ if(!spaces.length){box.innerHTML='<div class="empty">No permissioned spaces found for this account.</div>';return} 107 + \\ spaces.forEach((space,idx)=>{ 108 + \\ const p=spaceParts(space.uri); 109 + \\ const btn=document.createElement('button'); 110 + \\ btn.type='button'; btn.className='tab'; btn.role='tab'; btn.ariaSelected=idx===0?'true':'false'; 111 + \\ btn.textContent=p.type+' / '+p.skey; 112 + \\ btn.onclick=()=>selectSpace(space.uri); 113 + \\ box.appendChild(btn); 114 + \\ }); 115 + \\ selectSpace(spaces[0].uri); 116 + \\} 117 + \\async function selectSpace(uri){ 118 + \\ selectedSpace=uri; cursor=null; $('records').innerHTML=''; $('record-detail').className='empty'; $('record-detail').textContent='Choose a record.'; $('record-count').textContent='0'; 119 + \\ for(const btn of $('spaces').querySelectorAll('.tab')) btn.ariaSelected=btn.textContent===spaceParts(uri).type+' / '+spaceParts(uri).skey?'true':'false'; 120 + \\ const repo=$('repo'); repo.innerHTML=''; 121 + \\ const add=(did)=>{const opt=document.createElement('option');opt.value=did;opt.textContent=did;repo.appendChild(opt)}; 122 + \\ add(accountDid); 123 + \\ try{ 124 + \\ const out=await xrpc('/xrpc/com.atproto.space.getMembers',{space:uri,limit:'100'}); 125 + \\ for(const member of out.members||[]) if(member.did!==accountDid) add(member.did); 126 + \\ }catch(err){setStatus('signed in, but members could not be loaded: '+err.message,'warn')} 127 + \\} 128 + \\async function loadSpaces(){ 129 + \\ setStatus('loading spaces...'); 130 + \\ const out=await xrpc('/xrpc/com.atproto.space.listSpaces',{limit:'100'}); 131 + \\ renderSpaces(out.spaces||[]); 132 + \\ setStatus('signed in','ok'); 133 + \\} 134 + \\function renderRecords(records, append=false){ 135 + \\ const box=$('records'); if(!append) box.innerHTML=''; 136 + \\ if(!records.length && !append){box.innerHTML='<div class="empty">No records for that repo and collection.</div>'} 137 + \\ for(const rec of records){ 138 + \\ const btn=document.createElement('button'); btn.type='button'; btn.className='item'; 139 + \\ btn.innerHTML='<span class="item-title">'+esc(rec.collection)+' / '+esc(rec.rkey)+'</span><span class="item-meta">'+esc(rec.cid)+'</span>'; 140 + \\ btn.onclick=()=>openRecord(rec); 141 + \\ box.appendChild(btn); 142 + \\ } 143 + \\ $('record-count').textContent=String(box.querySelectorAll('.item').length); 144 + \\} 145 + \\async function loadRecords(append=false){ 146 + \\ if(!selectedSpace){setStatus('choose a space first','error');return} 147 + \\ const repo=$('repo').value.trim(); const collection=$('collection').value.trim(); 148 + \\ if(!repo){setStatus('choose a member repo','error');return} 149 + \\ if(!collection){setStatus('enter a collection NSID','error');return} 150 + \\ setStatus('loading records...'); 151 + \\ const params={space:selectedSpace,repo,collection,limit:'50'}; if(append&&cursor) params.cursor=cursor; 152 + \\ const out=await xrpc('/xrpc/com.atproto.space.listRecords',params); 153 + \\ cursor=out.cursor||null; $('next-records').disabled=!cursor; 154 + \\ renderRecords(out.records||[], append); 155 + \\ setStatus('records loaded','ok'); 156 + \\} 157 + \\function blobChips(value){ 158 + \\ const out=[]; const seen=new Set(); 159 + \\ function walk(v){ 160 + \\ if(!v||typeof v!=='object') return; 161 + \\ if(v.ref&&v.ref.$link&&!seen.has(v.ref.$link)){seen.add(v.ref.$link);out.push(v.ref.$link)} 162 + \\ for(const child of Array.isArray(v)?v:Object.values(v)) walk(child); 163 + \\ } 164 + \\ walk(value); 165 + \\ return out.map(cid=>'<span class="blob-chip">blob '+esc(cid)+'</span>').join(' '); 166 + \\} 167 + \\async function openRecord(rec){ 168 + \\ for(const btn of $('records').querySelectorAll('.item')) btn.ariaSelected='false'; 169 + \\ const detail=$('record-detail'); detail.className=''; detail.innerHTML='<p class="hint">loading record...</p>'; 170 + \\ const repo=$('repo').value.trim(); 171 + \\ const out=await xrpc('/xrpc/com.atproto.space.getRecord',{space:selectedSpace,repo,collection:rec.collection,rkey:rec.rkey}); 172 + \\ detail.innerHTML='<div class="record-head"><div><strong>'+esc(rec.collection)+' / '+esc(rec.rkey)+'</strong><span class="item-meta">'+esc(out.cid)+'</span></div><button class="secondary" type="button" id="copy-json">copy JSON</button></div>'+blobChips(out.value)+'<pre>'+esc(JSON.stringify(out.value,null,2))+'</pre>'; 173 + \\ $('copy-json').onclick=()=>navigator.clipboard.writeText(JSON.stringify(out.value,null,2)); 174 + \\} 175 + \\$('login-form').onsubmit=async(ev)=>{ 176 + \\ ev.preventDefault(); setStatus('signing in...'); 177 + \\ try{ 178 + \\ const identifier=$('identifier').value.trim(); const password=$('password').value; 179 + \\ const out=await xrpc('/xrpc/com.atproto.server.createSession',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({identifier,password})}); 180 + \\ token=out.accessJwt; accountDid=out.did; accountHandle=out.handle||identifier; 181 + \\ $('login-form').classList.add('off'); $('resident').classList.add('on'); $('account-label').innerHTML='Signed in as <strong>'+esc(accountHandle)+'</strong><br><code>'+esc(accountDid)+'</code>'; 182 + \\ await loadSpaces(); 183 + \\ }catch(err){setStatus(err.message,'error')} 184 + \\}; 185 + \\$('load-records').onclick=()=>loadRecords(false).catch(err=>setStatus(err.message,'error')); 186 + \\$('next-records').onclick=()=>loadRecords(true).catch(err=>setStatus(err.message,'error')); 187 + \\</script> 188 + \\</body> 189 + \\</html> 190 + ;