atproto user agency toolkit for individuals and groups
7
fork

Configure Feed

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

Add policy selection UI with consent checking and consent toggle

Three replication modes: bidirectional (mutual offers), archive with
consent (requires target opt-in record), and archive without consent.
Consent check fires async on account selection. Local user can toggle
their own consent via checkbox in Account section. Three new endpoints:
checkConsent, getMyConsent, setMyConsent.

+215 -15
+9
src/index.ts
··· 717 717 app.post("/xrpc/org.p2pds.app.rejectOffer", requireAuth, (c) => 718 718 app_routes.rejectOffer(c, replicationManager), 719 719 ); 720 + app.get("/xrpc/org.p2pds.app.checkConsent", requireAuth, (c) => 721 + app_routes.checkConsent(c, replicationManager), 722 + ); 723 + app.get("/xrpc/org.p2pds.app.getMyConsent", requireAuth, (c) => 724 + app_routes.getMyConsent(c, pdsClientRef), 725 + ); 726 + app.post("/xrpc/org.p2pds.app.setMyConsent", requireAuth, (c) => 727 + app_routes.setMyConsent(c, pdsClientRef), 728 + ); 720 729 721 730 // ============================================ 722 731 // MST Proof serving
+206 -15
src/xrpc/app.ts
··· 2 2 import type { AppEnv, AuthedAppEnv } from "../types.js"; 3 3 import type { ReplicationManager } from "../replication/replication-manager.js"; 4 4 import type { NetworkService } from "../ipfs.js"; 5 + import type { PdsClientRef } from "../oauth/routes.js"; 6 + import { CONSENT_NSID } from "../replication/types.js"; 5 7 6 8 const VERSION = "0.1.0"; 7 9 ··· 253 255 .verify-pass { color: #22c55e; font-weight: 600; } 254 256 .verify-fail { color: #ef4444; font-weight: 600; } 255 257 .loading { color: var(--faint); font-style: italic; } 256 - .add-did-form { display: flex; gap: 0.4rem; margin-bottom: 0.4rem; } 258 + .add-did-form { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; } 257 259 .add-did-form input { 258 260 flex: 1; padding: 0.3rem 0.5rem; font-family: inherit; font-size: 0.8rem; 259 261 border: 1px solid var(--input-border); border-radius: 3px; outline: none; ··· 261 263 } 262 264 .add-did-form input:focus { border-color: var(--fg); } 263 265 .add-did-form button, .btn-remove { 264 - padding: 0.3rem 0.6rem; font-family: inherit; font-size: 0.78rem; 266 + padding: 0.15rem 0.4rem; font-family: inherit; font-size: 0.7rem; 265 267 border: 1px solid var(--fg); border-radius: 3px; cursor: pointer; 266 268 background: var(--card-bg); color: var(--fg); 267 269 } ··· 316 318 .account-result-handle { font-size: 0.7rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 317 319 .account-selected { 318 320 display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; 319 - background: var(--selected-bg); border: 1px solid var(--selected-border); border-radius: 4px; margin-bottom: 0.4rem; 321 + background: var(--selected-bg); border: 1px solid var(--selected-border); border-radius: 4px; 320 322 } 321 323 .account-selected-info { flex: 1; min-width: 0; } 322 324 .account-selected-name { font-size: 0.8rem; font-weight: 600; } ··· 372 374 display: inline-block; width: 10px; height: 10px; border: 2px solid #eab30844; 373 375 border-top-color: #eab308; border-radius: 50%; animation: spin 0.8s linear infinite; 374 376 } 377 + .policy-selector { margin: 0.5rem 0 0.3rem; display: flex; flex-direction: column; gap: 0; } 378 + .policy-option { 379 + display: flex; align-items: flex-start; gap: 0.4rem; padding: 0.4rem 0.5rem; 380 + cursor: pointer; font-size: 0.78rem; border: 1px solid var(--border); 381 + border-bottom: none; transition: background 0.1s; 382 + } 383 + .policy-option:first-child { border-radius: 4px 4px 0 0; } 384 + .policy-option:last-child { border-bottom: 1px solid var(--border); border-radius: 0 0 4px 4px; } 385 + .policy-option:hover { background: var(--row-hover); } 386 + .policy-option.selected { background: var(--selected-bg); } 387 + .policy-option.disabled { opacity: 0.45; cursor: not-allowed; } 388 + .policy-option.disabled:hover { background: transparent; } 389 + .policy-option input[type="radio"] { margin-top: 2px; flex-shrink: 0; } 390 + .policy-option .policy-label { font-weight: 600; } 391 + .policy-option .policy-desc { color: var(--muted); font-size: 0.7rem; } 392 + .policy-option .policy-note { color: #eab308; font-size: 0.65rem; font-style: italic; } 393 + .consent-toggle { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.5rem; font-size: 0.78rem; cursor: pointer; } 394 + .consent-toggle input[type="checkbox"] { cursor: pointer; } 375 395 @media (prefers-color-scheme: dark) { 376 396 .source-pds { background: #1e3a5f; color: #93c5fd; } 377 397 .source-firehose { background: #422006; color: #fcd34d; } ··· 393 413 <body> 394 414 <header> 395 415 <h1>P2PDS</h1> 396 - <span class="badge" id="version-badge">v-</span> 416 + <span class="badge" id="version-badge">v${VERSION}</span> 397 417 <div class="meta"> 398 418 <span class="activity-spinner" id="activity-spinner"></span> 399 419 <span id="last-refresh">-</span> ··· 543 563 + "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" 544 564 + "<dt>Connections</dt><dd>" + esc(net.peerId ? (net.connections ?? 0) : null) + "</dd>" 545 565 + "</dl>"; 546 - document.getElementById("version-badge").textContent = "v" + data.version; 547 566 } 548 567 549 568 function renderMetrics(data) { ··· 838 857 + '</div>' 839 858 + '<div style="display:flex;align-items:center;gap:0.6rem;font-size:0.75rem">' 840 859 + '<span class="dot dot-synced"></span>Authenticated' 841 - + '<button class="btn-remove" onclick="if(confirm(\'Are you sure you want to disconnect?\\n\\nThis will delete all replicated data, revoke all offers, and remove your identity from this node. This cannot be undone.\')){fetch(\'/oauth/logout?disconnect=true\',{method:\'POST\'}).then(function(){location.href=\'/\';});}">Disconnect</button>' 842 - + '</div>'; 860 + + '<button class="btn-remove" id="disconnect-btn">Disconnect</button>' 861 + + '</div>' 862 + + '<label class="consent-toggle" id="consent-toggle">' 863 + + '<input type="checkbox" id="consent-checkbox">' 864 + + '<span>Allow anyone to archive my data</span>' 865 + + '</label>'; 866 + document.getElementById("disconnect-btn").addEventListener("click", function() { 867 + if (!confirm("Are you sure you want to disconnect?\\n\\nThis will delete all replicated data, revoke all offers, and remove your identity from this node. This cannot be undone.")) return; 868 + fetch("/oauth/logout?disconnect=true", { method: "POST" }).then(function() { location.href = "/"; }); 869 + }); 870 + // Load consent state 871 + var consentCb = document.getElementById("consent-checkbox"); 872 + apiFetch("org.p2pds.app.getMyConsent").then(function(res) { 873 + if (consentCb) consentCb.checked = !!res.consented; 874 + }).catch(function() {}); 875 + consentCb.addEventListener("change", function() { 876 + var cb = this; 877 + cb.disabled = true; 878 + apiPost("org.p2pds.app.setMyConsent", { enabled: cb.checked }).then(function() { 879 + cb.disabled = false; 880 + }).catch(function() { 881 + cb.checked = !cb.checked; 882 + cb.disabled = false; 883 + }); 884 + }); 843 885 } else { 844 886 accountSearchState = { selectedHandle: null, selectedActor: null, activeIndex: -1 }; 845 887 el.innerHTML = '<div id="account-search-container">' ··· 1132 1174 async function refresh() { 1133 1175 setActivity(true); 1134 1176 try { 1177 + // Check account status first — if OAuth is active but nobody is logged in, 1178 + // show empty sections (no stale data from a previous session) 1179 + await refreshAccount(); 1180 + if (cachedAccountStatus && cachedAccountStatus.authenticated === false) { 1181 + var empty = { did: "", handle: "", network: {}, replication: { enabled: false }, policy: {} }; 1182 + renderOverview(empty); 1183 + renderIncomingOffers(empty); 1184 + renderMetrics(empty); 1185 + renderReplication(empty); 1186 + renderSyncHistory({ history: [] }); 1187 + renderNetwork({ peerId: null, connections: 0, peers: [] }); 1188 + renderPolicies({ policies: [] }); 1189 + renderVerification(empty); 1190 + document.getElementById("last-refresh").textContent = "Updated: " + new Date().toLocaleTimeString(); 1191 + setActivity(false); 1192 + return; 1193 + } 1135 1194 const [overview, network, policies, syncHistory] = await Promise.all([ 1136 1195 apiFetch("org.p2pds.app.getOverview"), 1137 1196 apiFetch("org.p2pds.app.getNetworkStatus"), 1138 1197 apiFetch("org.p2pds.app.getPolicies"), 1139 1198 apiFetch("org.p2pds.app.getSyncHistory", { limit: "20" }), 1140 1199 ]); 1141 - await refreshAccount(); 1142 1200 renderOverview(overview); 1143 1201 renderIncomingOffers(overview); 1144 1202 renderMetrics(overview); ··· 1169 1227 refresh(); 1170 1228 1171 1229 // Add DID — account search widget 1172 - var didSearchState = { selectedDid: null, activeIndex: -1 }; 1230 + var didSearchState = { selectedDid: null, activeIndex: -1, selectedPolicy: "reciprocal" }; 1173 1231 var didSearchTimer = null; 1174 1232 1175 1233 async function addDidSubmit() { ··· 1179 1237 var did = didSearchState.selectedDid || input.value.trim(); 1180 1238 if (!did) { msgEl.className = "add-did-error"; msgEl.textContent = "Search for an account or paste a DID"; return; } 1181 1239 msgEl.className = ""; msgEl.textContent = ""; 1240 + var policy = didSearchState.selectedPolicy || "reciprocal"; 1241 + var endpoint = policy === "reciprocal" ? "org.p2pds.app.offerDid" : "org.p2pds.app.addDid"; 1182 1242 try { 1183 - var result = await apiPost("org.p2pds.app.offerDid", { did: did }); 1243 + var result = await apiPost(endpoint, { did: did }); 1184 1244 if (result.error) { 1185 1245 msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; 1186 1246 } else { 1187 1247 msgEl.className = "add-did-success"; 1188 1248 var dn = displayName(did); 1189 - msgEl.textContent = result.status === "already_tracked" 1190 - ? dn + " already tracked (source: " + result.source + ")" 1191 - : result.status === "already_offered" 1192 - ? dn + " already offered" 1193 - : "Offered to replicate " + dn; 1249 + if (result.status === "already_tracked") { 1250 + msgEl.textContent = dn + " already tracked (source: " + result.source + ")"; 1251 + } else if (policy === "reciprocal") { 1252 + msgEl.textContent = result.status === "already_offered" 1253 + ? dn + " already offered" 1254 + : "Offered to replicate " + dn; 1255 + } else if (policy === "consented") { 1256 + msgEl.textContent = "Archiving " + dn + " (with consent)"; 1257 + } else { 1258 + msgEl.textContent = "Archiving " + dn; 1259 + } 1194 1260 input.value = ""; 1195 1261 didSearchState.selectedDid = null; 1262 + didSearchState.selectedPolicy = "reciprocal"; 1196 1263 selectedEl.style.display = "none"; 1197 1264 selectedEl.innerHTML = ""; 1198 1265 document.getElementById("did-search-wrap").style.display = ""; ··· 1221 1288 + '<button class="account-selected-clear" id="did-clear-btn">Change</button>' 1222 1289 + '</div>'; 1223 1290 selectedEl.style.display = "block"; 1291 + // Append policy selector with 3 options 1292 + var policyHtml = '<div class="policy-selector">' 1293 + + '<label class="policy-option selected"><input type="radio" name="add-did-policy" value="reciprocal" checked>' 1294 + + '<span><span class="policy-label">Bidirectional archiving with mutual consent</span><br><span class="policy-desc">Both peers replicate data bidirectionally</span></span></label>' 1295 + + '<label class="policy-option disabled" id="policy-consented-option"><input type="radio" name="add-did-policy" value="consented" disabled>' 1296 + + '<span><span class="policy-label">Archive with consent</span><br><span class="policy-desc">Back up their data (they opted in)</span><br><span class="policy-note" id="consent-status-note">Checking consent...</span></span></label>' 1297 + + '<label class="policy-option"><input type="radio" name="add-did-policy" value="archive">' 1298 + + '<span><span class="policy-label">Archive without explicit consent</span><br><span class="policy-desc">Back up their data locally (one-way)</span></span></label>' 1299 + + '</div>'; 1300 + selectedEl.insertAdjacentHTML("beforeend", policyHtml); 1301 + didSearchState.selectedPolicy = "reciprocal"; 1302 + // Check consent async 1303 + apiFetch("org.p2pds.app.checkConsent", { did: actor.did }).then(function(res) { 1304 + var note = document.getElementById("consent-status-note"); 1305 + var option = document.getElementById("policy-consented-option"); 1306 + var radio = option ? option.querySelector("input") : null; 1307 + if (res.hasConsent) { 1308 + if (note) note.textContent = ""; 1309 + if (option) option.classList.remove("disabled"); 1310 + if (radio) radio.disabled = false; 1311 + } else { 1312 + if (note) note.textContent = "Account hasn't opted in"; 1313 + } 1314 + }).catch(function() { 1315 + var note = document.getElementById("consent-status-note"); 1316 + if (note) note.textContent = "Could not check consent"; 1317 + }); 1318 + selectedEl.querySelectorAll('input[name="add-did-policy"]').forEach(function(r) { 1319 + r.addEventListener("change", function() { 1320 + didSearchState.selectedPolicy = this.value; 1321 + selectedEl.querySelectorAll(".policy-option").forEach(function(opt) { opt.classList.remove("selected"); }); 1322 + this.closest(".policy-option").classList.add("selected"); 1323 + }); 1324 + }); 1224 1325 document.getElementById("did-clear-btn").addEventListener("click", function() { 1225 1326 didSearchState.selectedDid = null; 1327 + didSearchState.selectedPolicy = "reciprocal"; 1226 1328 selectedEl.style.display = "none"; 1227 1329 selectedEl.innerHTML = ""; 1228 1330 document.getElementById("did-search-wrap").style.display = ""; ··· 1787 1889 1788 1890 return c.json({ status: "revoked", did }); 1789 1891 } 1892 + 1893 + /** 1894 + * Check if a remote DID has published a consent record. 1895 + */ 1896 + export async function checkConsent( 1897 + c: Context<AuthedAppEnv>, 1898 + replicationManager: ReplicationManager | undefined, 1899 + ): Promise<Response> { 1900 + const did = c.req.query("did"); 1901 + if (!did || !isValidDid(did)) { 1902 + return c.json( 1903 + { error: "MissingParameter", message: "did is required and must be valid" }, 1904 + 400, 1905 + ); 1906 + } 1907 + 1908 + if (!replicationManager) { 1909 + return c.json( 1910 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 1911 + 400, 1912 + ); 1913 + } 1914 + 1915 + const repoFetcher = replicationManager.getRepoFetcher(); 1916 + const pdsEndpoint = await repoFetcher.resolvePds(did); 1917 + if (!pdsEndpoint) { 1918 + return c.json({ did, hasConsent: false }); 1919 + } 1920 + 1921 + const record = await repoFetcher.fetchRecord(pdsEndpoint, did, CONSENT_NSID, "self"); 1922 + return c.json({ did, hasConsent: record != null }); 1923 + } 1924 + 1925 + /** 1926 + * Check if the local user has published a consent record. 1927 + */ 1928 + export async function getMyConsent( 1929 + c: Context<AuthedAppEnv>, 1930 + pdsClientRef: PdsClientRef | undefined, 1931 + ): Promise<Response> { 1932 + if (!pdsClientRef?.current) { 1933 + return c.json( 1934 + { error: "NotAuthenticated", message: "No authenticated session" }, 1935 + 401, 1936 + ); 1937 + } 1938 + 1939 + try { 1940 + const result = await pdsClientRef.current.listRecords(CONSENT_NSID, { limit: 1 }); 1941 + return c.json({ consented: result.records.length > 0 }); 1942 + } catch { 1943 + return c.json({ consented: false }); 1944 + } 1945 + } 1946 + 1947 + /** 1948 + * Toggle the local user's consent record. 1949 + */ 1950 + export async function setMyConsent( 1951 + c: Context<AuthedAppEnv>, 1952 + pdsClientRef: PdsClientRef | undefined, 1953 + ): Promise<Response> { 1954 + if (!pdsClientRef?.current) { 1955 + return c.json( 1956 + { error: "NotAuthenticated", message: "No authenticated session" }, 1957 + 401, 1958 + ); 1959 + } 1960 + 1961 + const body = await c.req.json<{ enabled?: boolean }>().catch(() => ({}) as { enabled?: boolean }); 1962 + if (typeof body.enabled !== "boolean") { 1963 + return c.json( 1964 + { error: "MissingParameter", message: "enabled (boolean) is required" }, 1965 + 400, 1966 + ); 1967 + } 1968 + 1969 + if (body.enabled) { 1970 + await pdsClientRef.current.putRecord(CONSENT_NSID, "self", { 1971 + $type: CONSENT_NSID, 1972 + scope: "any", 1973 + createdAt: new Date().toISOString(), 1974 + }); 1975 + } else { 1976 + await pdsClientRef.current.deleteRecord(CONSENT_NSID, "self"); 1977 + } 1978 + 1979 + return c.json({ consented: body.enabled }); 1980 + }