this repo has no description
1
fork

Configure Feed

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

client-render linkages, OAuth-required unlink

The linked-state view is now rendered client-side from the user's PDS, with the DID held in browser localStorage, so the server holds no long-lived session and the view survives cookie clears. Unlink now triggers fresh atproto OAuth and deletes both the attestation (via attester creds) and the user's claim (via the just-returned OAuth session), removing orphan-pointer cases.

+251 -133
+4 -4
src/cookies.js
··· 1 1 import { createHmac, timingSafeEqual } from 'node:crypto'; 2 2 3 3 const COOKIE_NAME = 'pa_session'; 4 - const MAX_AGE_SECONDS = 15 * 60; 4 + const DEFAULT_MAX_AGE = 15 * 60; 5 5 6 6 const sign = (payload, secret) => { 7 7 const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); ··· 21 21 return payload; 22 22 }; 23 23 24 - export const setSession = (c, payload, secret) => { 25 - const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SECONDS; 24 + export const setSession = (c, payload, secret, maxAge = DEFAULT_MAX_AGE) => { 25 + const exp = Math.floor(Date.now() / 1000) + maxAge; 26 26 const token = sign({ ...payload, exp }, secret); 27 27 c.header('Set-Cookie', 28 - `${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${MAX_AGE_SECONDS}`); 28 + `${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`); 29 29 }; 30 30 31 31 export const getSession = (c, secret) => {
+247 -129
src/server.js
··· 18 18 const PUBLIC_URL = process.env.PUBLIC_URL; 19 19 const SESSION_SECRET = process.env.SESSION_SECRET; 20 20 const ATTESTER_DID = process.env.ATTESTER_DID; 21 + const ATTESTER_PDS_URL = process.env.ATTESTER_PDS_URL; 21 22 const UA_GUILD_ID = process.env.UA_GUILD_ID; 22 23 const UA_FASCINATOR_ROLE_ID = process.env.UA_FASCINATOR_ROLE_ID; 23 24 const DISCORD_REDIRECT = `${PUBLIC_URL}/discord/callback`; 24 25 25 26 const oauth = await buildOAuthClient({ publicUrl: PUBLIC_URL }); 26 27 27 - const resolveDidDoc = async (did) => { 28 - const url = did.startsWith('did:plc:') 29 - ? `https://plc.directory/${did}` 30 - : `https://${did.replace(/^did:web:/, '')}/.well-known/did.json`; 31 - const res = await fetch(url); 32 - if (!res.ok) throw new Error(`DID doc fetch failed: ${res.status}`); 33 - const doc = await res.json(); 34 - const handle = (doc.alsoKnownAs ?? []) 35 - .find((a) => typeof a === 'string' && a.startsWith('at://'))?.slice(5); 36 - const pdsEndpoint = (doc.service ?? []) 37 - .find((s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; 38 - const pds = pdsEndpoint ? new URL(pdsEndpoint).hostname : null; 39 - return { handle, pds }; 40 - }; 41 - 42 28 const errorPage = (c, message) => c.html(` 43 29 <!doctype html> 44 30 <meta charset="utf-8"> ··· 50 36 51 37 const app = new Hono(); 52 38 53 - app.get('/', async (c) => { 39 + app.get('/', (c) => { 54 40 const s = getSession(c, SESSION_SECRET); 55 - const discordLinked = !!s?.discord; 56 - const atmosphereLinked = !!s?.atmosphere?.did; 41 + const discordInFlight = !!s?.discord; 57 42 58 - if (atmosphereLinked && (!s.atmosphere.handle || !s.atmosphere.pds)) { 59 - try { 60 - const { handle, pds } = await resolveDidDoc(s.atmosphere.did); 61 - if (handle) s.atmosphere.handle = handle; 62 - if (pds) s.atmosphere.pds = pds; 63 - setSession(c, { discord: s.discord, atmosphere: s.atmosphere }, SESSION_SECRET); 64 - } catch (err) { 65 - console.error('lazy did doc resolve failed', err); 66 - } 67 - } 68 - 69 - const discordCard = discordLinked ? ` 43 + const discordCard = discordInFlight ? ` 70 44 <ul> 71 45 <li>User: <code>${s.discord.username ?? s.discord.userId}</code></li> 72 46 <li>Guild: <code>User &amp; Agents</code></li> 73 47 ${s.discord.role ? `<li>Role: <code>${s.discord.role}</code></li>` : ''} 74 48 </ul> 75 - <form action="/unlink" method="post"><button type="submit">Unlink</button></form> 49 + <form action="/cancel" method="post"><button type="submit">Cancel</button></form> 76 50 ` : ` 77 51 <p><a href="/discord/start"><button type="button">Link Discord account</button></a></p> 78 52 `; 79 53 80 - let atmosphereCard; 81 - if (atmosphereLinked) { 82 - atmosphereCard = ` 83 - <ul> 84 - <li>Handle: <code>${s.atmosphere.handle ?? '(unknown)'}</code></li> 85 - <li>PDS: <code>${s.atmosphere.pds ?? '(unknown)'}</code></li> 86 - <li>DID: <code>${s.atmosphere.did}</code></li> 87 - </ul> 88 - <form action="/unlink" method="post"><button type="submit">Unlink</button></form> 89 - `; 90 - } else if (discordLinked) { 91 - atmosphereCard = ` 92 - <form action="/login" method="get"> 93 - <input name="handle" placeholder="you.bsky.social" required> 94 - <button type="submit">Sign in</button> 95 - </form> 96 - <p><small>Signing in writes the link and completes the flow.</small></p> 97 - `; 98 - } else { 99 - atmosphereCard = ` 100 - <form action="/login" method="get"> 101 - <input name="handle" placeholder="you.bsky.social" disabled> 102 - <button type="submit" disabled>Sign in</button> 103 - </form> 104 - <p><small>Link a Discord account first.</small></p> 105 - `; 106 - } 54 + const atmosphereCard = discordInFlight ? ` 55 + <form action="/login" method="get"> 56 + <input name="handle" placeholder="you.bsky.social" required> 57 + <button type="submit">Sign in</button> 58 + </form> 59 + <p><small>Signing in writes the link and completes the flow.</small></p> 60 + ` : ` 61 + <form action="/login" method="get"> 62 + <input name="handle" placeholder="you.bsky.social" disabled> 63 + <button type="submit" disabled>Sign in</button> 64 + </form> 65 + <p><small>Link a Discord account first.</small></p> 66 + `; 107 67 108 - let recordsCard; 109 - if (atmosphereLinked && discordLinked) { 110 - const attUri = `at://${ATTESTER_DID}/agency.portable.attestation/${s.atmosphere.attRkey}`; 111 - const memUri = `at://${s.atmosphere.did}/agency.portable.membership/${s.atmosphere.memRkey}`; 112 - recordsCard = ` 113 - <ul> 114 - <li><a href="https://pdsls.dev/${attUri}" target="_blank" rel="noopener">Attestation</a> on portable.agency's PDS</li> 115 - <li><a href="https://pdsls.dev/${memUri}" target="_blank" rel="noopener">Claim</a> on your PDS</li> 116 - </ul> 117 - `; 118 - } else { 119 - recordsCard = ` 120 - <ul> 121 - <li>Attestation on portable.agency's PDS</li> 122 - <li>Claim on your PDS</li> 123 - </ul> 124 - <p><small>Written when you complete both steps.</small></p> 125 - `; 126 - } 68 + const recordsCard = ` 69 + <ul> 70 + <li>Attestation on portable.agency's PDS</li> 71 + <li>Claim on your PDS</li> 72 + </ul> 73 + <p><small>Written when you complete both steps.</small></p> 74 + `; 75 + 76 + const serverRow = ` 77 + <div class="row"> 78 + <div class="card"> 79 + <h2>Discord &mdash; User &amp; Agents</h2> 80 + ${discordCard} 81 + </div> 82 + <div class="connector" aria-hidden="true"></div> 83 + <div class="card"> 84 + <h2>Atmosphere account</h2> 85 + ${atmosphereCard} 86 + </div> 87 + <div class="connector" aria-hidden="true"></div> 88 + <div class="card"> 89 + <h2>Linked records</h2> 90 + ${recordsCard} 91 + </div> 92 + </div> 93 + `; 127 94 128 95 return c.html(` 129 96 <!doctype html> ··· 152 119 </style> 153 120 <h1>portable.agency</h1> 154 121 <p>Link your platformed accounts to an Atmosphere account.</p> 155 - <div class="rows"> 156 - <div class="row"> 157 - <div class="card"> 158 - <h2>Discord &mdash; User &amp; Agents</h2> 159 - ${discordCard} 160 - </div> 161 - <div class="connector" aria-hidden="true"></div> 162 - <div class="card"> 163 - <h2>Atmosphere account</h2> 164 - ${atmosphereCard} 165 - </div> 166 - <div class="connector" aria-hidden="true"></div> 167 - <div class="card"> 168 - <h2>Linked records</h2> 169 - ${recordsCard} 170 - </div> 171 - </div> 122 + <div id="rows" class="rows" data-midflow="${discordInFlight ? '1' : ''}"> 123 + ${serverRow} 172 124 </div> 173 125 <section class="how"> 174 126 <h2>How this works</h2> 175 - <p>Each linkage is stored as a <em>pair of records</em> on atproto PDSes, so it survives portable.agency disappearing and can be verified by anyone.</p> 127 + <p>Each linkage is a <em>pair of records</em> on atproto PDSes &mdash; one under your control, one under portable.agency's. Those records are the only durable state; there's no service database to go stale or lose.</p> 176 128 <ol> 177 - <li><strong>Link a platformed account.</strong> Authorize the external service (e.g. Discord) so we can confirm your membership and role. Nothing is written yet.</li> 178 - <li><strong>Sign in with your Atmosphere account.</strong> Fine-grained OAuth — we only request permission to write to the <code>agency.portable.membership</code> collection.</li> 129 + <li><strong>Link a platformed account.</strong> Authorize the external service (e.g. Discord) so we can confirm your membership and any relevant role. Nothing is written yet.</li> 130 + <li><strong>Sign in with your Atmosphere account.</strong> Fine-grained OAuth &mdash; we only request permission to write to the <code>agency.portable.membership</code> collection.</li> 179 131 <li><strong>Two records are written.</strong> 180 132 <ul> 181 - <li>An <strong>attestation</strong> (<code>agency.portable.attestation</code>) on portable.agency's PDS — a third-party statement that your DID owns the linked account.</li> 182 - <li>A <strong>claim</strong> (<code>agency.portable.membership</code>) on your own PDS — a self-claim naming portable.agency as the attester.</li> 133 + <li>An <strong>attestation</strong> (<code>agency.portable.attestation</code>) on portable.agency's PDS &mdash; a third-party statement that your DID owns the linked account.</li> 134 + <li>A <strong>claim</strong> (<code>agency.portable.membership</code>) on your own PDS &mdash; a self-claim naming portable.agency as the attester.</li> 183 135 </ul> 184 136 Both records carry the same <code>service</code> block. Matching them is the proof. 185 137 </li> 186 138 </ol> 187 - <p>Record keys are deterministic (hash of <code>did + type + community + identifier</code>), so re-linking the same account is idempotent, but linking multiple accounts on the same platform creates separate records. We store no session-level state beyond the in-flight OAuth handshakes.</p> 139 + <p><strong>Multiple linkages.</strong> Record keys are deterministic (hash of <code>did + service.type + community + identifier</code>), so re-linking the <em>same</em> external account is idempotent; linking a <em>different</em> account (e.g. a second Discord alt) creates a separate record. You can have N linkages per platform.</p> 140 + <p><strong>Where your state lives.</strong> Nothing server-side except a short-lived cookie that holds the in-flight OAuth handshake. After a successful link, your browser stores just your DID in <code>localStorage</code>. When you load this page, the browser uses that DID to query your PDS directly and renders the linkages it finds &mdash; no server involvement. Clearing browser data only clears the view, not the linkages themselves.</p> 141 + <p><strong>Verification requires both halves.</strong> The two records reference each other by DID, so confirming a linkage means fetching both &mdash; your claim from your PDS and portable.agency's attestation from its PDS. If portable.agency's PDS goes away without a repo backup, the attestation half is lost; your self-claim remains but becomes unverifiable on its own. Atproto repos are cryptographically signed, so anyone archiving portable.agency's repo could keep the attestations verifiable independently.</p> 142 + <p><strong>Unlinking.</strong> Unlinking requires signing in with your Atmosphere account to prove ownership of the DID. Clicking Unlink redirects you to atproto OAuth; once you confirm, both records &mdash; the attestation on portable.agency's PDS and the matching claim on your own PDS &mdash; are deleted, leaving no orphan pointers.</p> 188 143 </section> 144 + <script> 145 + const ATTESTER_DID = ${JSON.stringify(ATTESTER_DID)}; 146 + const ATTESTER_PDS_URL = ${JSON.stringify(ATTESTER_PDS_URL)}; 147 + 148 + const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ 149 + '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', 150 + }[c])); 151 + 152 + async function sha256Hex(str) { 153 + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); 154 + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join(''); 155 + } 156 + 157 + const attestationRkey = (subject, service) => 158 + sha256Hex([subject, service.type, service.community ?? '', service.identifier ?? ''].join('|')); 159 + 160 + const membershipRkey = (attestedBy, service) => 161 + sha256Hex([attestedBy, service.type, service.community ?? '', service.identifier ?? ''].join('|')); 162 + 163 + async function resolveDidDoc(did) { 164 + const url = did.startsWith('did:plc:') 165 + ? 'https://plc.directory/' + did 166 + : 'https://' + did.replace(/^did:web:/, '') + '/.well-known/did.json'; 167 + const res = await fetch(url); 168 + if (!res.ok) throw new Error('did doc fetch failed'); 169 + const doc = await res.json(); 170 + const handle = (doc.alsoKnownAs || []) 171 + .find((a) => typeof a === 'string' && a.startsWith('at://'))?.slice(5); 172 + const pdsEndpoint = (doc.service || []) 173 + .find((s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; 174 + const pds = pdsEndpoint ? new URL(pdsEndpoint).hostname : null; 175 + return { handle, pds, pdsUrl: pdsEndpoint }; 176 + } 177 + 178 + async function listMemberships(did, pdsUrl) { 179 + const url = pdsUrl + '/xrpc/com.atproto.repo.listRecords?repo=' + encodeURIComponent(did) + 180 + '&collection=agency.portable.membership'; 181 + const res = await fetch(url); 182 + if (!res.ok) return []; 183 + const json = await res.json(); 184 + return json.records || []; 185 + } 186 + 187 + function labelForService(service) { 188 + if (service.type === 'discord') return 'Discord'; 189 + return service.type; 190 + } 191 + 192 + function cardHtml(title, body) { 193 + return '<div class="card"><h2>' + title + '</h2>' + body + '</div>'; 194 + } 195 + 196 + async function renderLinkage(did, atmo, membership) { 197 + const svc = membership.value.service; 198 + const role = membership.value.role; 199 + const memUri = membership.uri; 200 + const attRkey = await attestationRkey(did, svc); 201 + const attUri = 'at://' + ATTESTER_DID + '/agency.portable.attestation/' + attRkey; 202 + const pdslsAtt = 'https://pdsls.dev/' + attUri; 203 + const pdslsMem = 'https://pdsls.dev/' + memUri; 204 + 205 + const externalBody = 206 + '<ul>' + 207 + '<li>Service: <code>' + escapeHtml(labelForService(svc)) + '</code></li>' + 208 + (svc.community ? '<li>Community: <code>' + escapeHtml(svc.community) + '</code></li>' : '') + 209 + (svc.identifier ? '<li>ID: <code>' + escapeHtml(svc.identifier) + '</code></li>' : '') + 210 + (role ? '<li>Role: <code>' + escapeHtml(role) + '</code></li>' : '') + 211 + '</ul>' + 212 + '<form method="post" action="/unlink">' + 213 + '<input type="hidden" name="did" value="' + escapeHtml(did) + '">' + 214 + '<input type="hidden" name="type" value="' + escapeHtml(svc.type) + '">' + 215 + (svc.community ? '<input type="hidden" name="community" value="' + escapeHtml(svc.community) + '">' : '') + 216 + (svc.identifier ? '<input type="hidden" name="identifier" value="' + escapeHtml(svc.identifier) + '">' : '') + 217 + '<button type="submit">Unlink (sign in to confirm)</button>' + 218 + '</form>'; 219 + 220 + const atmoBody = 221 + '<ul>' + 222 + '<li>Handle: <code>' + escapeHtml(atmo.handle ?? '(unknown)') + '</code></li>' + 223 + '<li>PDS: <code>' + escapeHtml(atmo.pds ?? '(unknown)') + '</code></li>' + 224 + '<li>DID: <code>' + escapeHtml(did) + '</code></li>' + 225 + '</ul>'; 226 + 227 + const recordsBody = 228 + '<ul>' + 229 + '<li><a href="' + pdslsAtt + '" target="_blank" rel="noopener">Attestation</a> on portable.agency\\'s PDS</li>' + 230 + '<li><a href="' + pdslsMem + '" target="_blank" rel="noopener">Claim</a> on your PDS</li>' + 231 + '</ul>'; 232 + 233 + const row = document.createElement('div'); 234 + row.className = 'row'; 235 + row.innerHTML = 236 + cardHtml(escapeHtml(labelForService(svc)) + (svc.community === ${JSON.stringify(UA_GUILD_ID)} ? ' &mdash; User &amp; Agents' : ''), externalBody) + 237 + '<div class="connector" aria-hidden="true"></div>' + 238 + cardHtml('Atmosphere account', atmoBody) + 239 + '<div class="connector" aria-hidden="true"></div>' + 240 + cardHtml('Linked records', recordsBody); 241 + return row; 242 + } 243 + 244 + async function bootstrap() { 245 + const container = document.getElementById('rows'); 246 + const midflow = container.dataset.midflow === '1'; 247 + if (midflow) return; 248 + const did = localStorage.getItem('pa.did'); 249 + if (!did) return; 250 + let atmo; 251 + try { 252 + atmo = await resolveDidDoc(did); 253 + } catch (err) { 254 + console.error('resolve did doc failed', err); 255 + return; 256 + } 257 + let memberships; 258 + try { 259 + memberships = await listMemberships(did, atmo.pdsUrl); 260 + } catch (err) { 261 + console.error('list memberships failed', err); 262 + return; 263 + } 264 + if (!memberships.length) { 265 + localStorage.removeItem('pa.did'); 266 + return; 267 + } 268 + container.innerHTML = ''; 269 + for (const m of memberships) { 270 + container.appendChild(await renderLinkage(did, atmo, m)); 271 + } 272 + } 273 + 274 + bootstrap(); 275 + </script> 189 276 `); 190 277 }); 191 278 279 + app.post('/cancel', (c) => { 280 + clearSession(c); 281 + return c.redirect('/'); 282 + }); 283 + 192 284 app.post('/unlink', async (c) => { 193 - const s = getSession(c, SESSION_SECRET); 194 - if (s?.atmosphere?.attRkey) { 195 - try { 196 - await deleteAttestation({ rkey: s.atmosphere.attRkey }); 197 - } catch (err) { 198 - console.error('attestation delete failed', err); 199 - } 285 + const form = await c.req.formData(); 286 + const did = form.get('did'); 287 + const type = form.get('type'); 288 + const community = form.get('community') || undefined; 289 + const identifier = form.get('identifier') || undefined; 290 + if (!did || !type) return errorPage(c, 'Missing unlink parameters.'); 291 + const service = { type, ...(community ? { community } : {}), ...(identifier ? { identifier } : {}) }; 292 + const state = randomBytes(16).toString('hex'); 293 + setSession(c, { unlink: { did, service } }, SESSION_SECRET); 294 + try { 295 + const url = await oauth.authorize(did, { state }); 296 + return c.redirect(url.toString()); 297 + } catch (err) { 298 + console.error('unlink authorize failed', err); 299 + clearSession(c); 300 + return errorPage(c, 'Could not start sign-in to unlink.'); 200 301 } 201 - clearSession(c); 202 - return c.redirect('/'); 203 302 }); 204 303 205 304 app.get('/client-metadata.json', (c) => c.json(oauth.clientMetadata)); ··· 245 344 246 345 const role = (member.roles ?? []).includes(UA_FASCINATOR_ROLE_ID) ? 'fascinator' : undefined; 247 346 setSession(c, { 248 - discord: { 249 - userId: user.id, 250 - username: user.username, 251 - role, 252 - }, 347 + discord: { userId: user.id, username: user.username, role }, 253 348 }, SESSION_SECRET); 254 349 return c.redirect('/'); 255 350 }); ··· 266 361 267 362 app.get('/oauth/callback', async (c) => { 268 363 const s = getSession(c, SESSION_SECRET); 269 - if (!s?.discord) { 364 + if (!s?.discord && !s?.unlink) { 270 365 clearSession(c); 271 - return errorPage(c, 'Missing Discord linkage state. Start over.'); 366 + return errorPage(c, 'Missing flow state. Start over.'); 272 367 } 273 368 274 369 let session; ··· 281 376 return errorPage(c, 'Sign-in with atmosphere account failed. Try again.'); 282 377 } 283 378 379 + if (s.unlink) { 380 + if (session.did !== s.unlink.did) { 381 + clearSession(c); 382 + return errorPage(c, 'Signed-in DID does not match the linkage you asked to unlink.'); 383 + } 384 + const service = s.unlink.service; 385 + const attRkey = attestationRkey({ subject: session.did, service }); 386 + const memRkey = membershipRkey({ attestedBy: ATTESTER_DID, service }); 387 + try { 388 + await deleteAttestation({ rkey: attRkey }); 389 + } catch (err) { 390 + console.error('attestation delete failed', err); 391 + } 392 + try { 393 + const bskyAgent = new Agent(session); 394 + await bskyAgent.com.atproto.repo.deleteRecord({ 395 + repo: session.did, 396 + collection: 'agency.portable.membership', 397 + rkey: memRkey, 398 + }); 399 + } catch (err) { 400 + console.error('membership delete failed', err); 401 + } 402 + clearSession(c); 403 + return c.redirect('/'); 404 + } 405 + 284 406 const service = { 285 407 type: 'discord', 286 408 community: UA_GUILD_ID, 287 409 identifier: s.discord.userId, 288 410 }; 289 411 const role = s.discord.role; 290 - const attRkey = attestationRkey({ subject: session.did, service }); 291 412 const memRkey = membershipRkey({ attestedBy: ATTESTER_DID, service }); 292 413 const createdAt = new Date().toISOString(); 293 414 ··· 312 433 return errorPage(c, 'Could not write linkage records. Please try again.'); 313 434 } 314 435 315 - let handle, pds; 316 - try { 317 - ({ handle, pds } = await resolveDidDoc(session.did)); 318 - } catch (err) { 319 - console.error('did doc resolve failed', err); 320 - } 321 - 322 - setSession(c, { 323 - discord: s.discord, 324 - atmosphere: { did: session.did, handle, pds, attRkey, memRkey }, 325 - }, SESSION_SECRET); 326 - return c.redirect('/'); 436 + clearSession(c); 437 + return c.html(` 438 + <!doctype html> 439 + <meta charset="utf-8"> 440 + <script> 441 + localStorage.setItem('pa.did', ${JSON.stringify(session.did)}); 442 + location.replace('/'); 443 + </script> 444 + `); 327 445 }); 328 446 329 447 const port = Number(process.env.PORT ?? 3000);