this repo has no description
1
fork

Configure Feed

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

publish lexicons, harden handle input, prevent flash on load

- add src/publish-lexicons.js + publish:lexicons script to push
agency.portable.{membership,attestation} as com.atproto.lexicon.schema
records on the attester's PDS (resolves via _lexicon.portable.agency DNS)
- normalize handle input in /login: strip leading @, extract from
bsky.app/profile URLs, drop trailing dot; catch resolve errors to
show a friendly message instead of 500
- validate NSID format in /lexicons/:nsid to block path traversal
- hide #rows on load when localStorage has a DID, unhide after client
render completes — stops the empty-form flash before populated view
- pin Railway service in npm scripts (deploy, logs) so the CLI
doesn't prompt when link state is lost

+79 -24
+4 -1
package.json
··· 5 5 "main": "src/server.js", 6 6 "scripts": { 7 7 "start": "node src/server.js", 8 - "dev": "node --watch src/server.js" 8 + "dev": "node --watch src/server.js", 9 + "publish:lexicons": "node src/publish-lexicons.js", 10 + "deploy": "railway up --service balanced-cooperation", 11 + "logs": "railway logs --service balanced-cooperation" 9 12 }, 10 13 "dependencies": { 11 14 "@atproto/api": "^0.13.0",
+28
src/publish-lexicons.js
··· 1 + import { readFile, readdir } from 'node:fs/promises'; 2 + import { fileURLToPath } from 'node:url'; 3 + import { dirname, join } from 'node:path'; 4 + import 'dotenv/config'; 5 + import { AtpAgent } from '@atproto/api'; 6 + 7 + const LEXICONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'lexicons'); 8 + 9 + const agent = new AtpAgent({ service: process.env.ATTESTER_PDS_URL }); 10 + await agent.login({ 11 + identifier: process.env.ATTESTER_IDENTIFIER, 12 + password: process.env.ATTESTER_APP_PASSWORD, 13 + }); 14 + 15 + const files = (await readdir(LEXICONS_DIR)).filter((f) => f.endsWith('.json')); 16 + 17 + for (const file of files) { 18 + const doc = JSON.parse(await readFile(join(LEXICONS_DIR, file), 'utf8')); 19 + const nsid = doc.id; 20 + const record = { $type: 'com.atproto.lexicon.schema', ...doc }; 21 + const res = await agent.com.atproto.repo.putRecord({ 22 + repo: process.env.ATTESTER_DID, 23 + collection: 'com.atproto.lexicon.schema', 24 + rkey: nsid, 25 + record, 26 + }); 27 + console.log(`published ${nsid} → ${res.data.uri}`); 28 + }
+47 -23
src/server.js
··· 122 122 <div id="rows" class="rows" data-midflow="${discordInFlight ? '1' : ''}"> 123 123 ${serverRow} 124 124 </div> 125 + <script> 126 + (function () { 127 + const el = document.getElementById('rows'); 128 + if (el.dataset.midflow === '1') return; 129 + if (localStorage.getItem('pa.did')) el.style.visibility = 'hidden'; 130 + })(); 131 + </script> 125 132 <section class="how"> 126 133 <h2>How this works</h2> 127 134 <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> ··· 247 254 if (midflow) return; 248 255 const did = localStorage.getItem('pa.did'); 249 256 if (!did) return; 250 - let atmo; 251 257 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); 258 + const atmo = await resolveDidDoc(did); 259 + const memberships = await listMemberships(did, atmo.pdsUrl); 260 + if (!memberships.length) { 261 + localStorage.removeItem('pa.did'); 262 + return; 263 + } 264 + container.innerHTML = ''; 265 + for (const m of memberships) { 266 + container.appendChild(await renderLinkage(did, atmo, m)); 267 + } 260 268 } 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)); 269 + console.error('bootstrap failed', err); 270 + } finally { 271 + container.style.visibility = ''; 271 272 } 272 273 } 273 274 ··· 307 308 308 309 app.get('/lexicons/:nsid', async (c) => { 309 310 const nsid = c.req.param('nsid').replace(/\.json$/, ''); 311 + if (!/^[a-z][a-z0-9]*(\.[a-z0-9-]+)+$/i.test(nsid)) return c.text('not found', 404); 310 312 try { 311 313 const body = await readFile(join(LEXICONS_DIR, `${nsid}.json`), 'utf8'); 312 314 return c.body(body, 200, { 'content-type': 'application/json' }); ··· 349 351 return c.redirect('/'); 350 352 }); 351 353 354 + const normalizeHandle = (raw) => { 355 + let h = raw.trim(); 356 + if (h.startsWith('@')) h = h.slice(1); 357 + if (h.includes('/')) { 358 + try { 359 + const u = new URL(h.includes('://') ? h : `https://${h}`); 360 + if (u.pathname.startsWith('/profile/')) { 361 + h = decodeURIComponent(u.pathname.slice('/profile/'.length).split('/')[0]); 362 + if (h.startsWith('@')) h = h.slice(1); 363 + } 364 + } catch {} 365 + } 366 + if (h.endsWith('.')) h = h.slice(0, -1); 367 + return h; 368 + }; 369 + 352 370 app.get('/login', async (c) => { 353 371 const s = getSession(c, SESSION_SECRET); 354 372 if (!s?.discord) return c.redirect('/'); 355 - const handle = c.req.query('handle'); 356 - if (!handle) return errorPage(c, 'Missing handle.'); 373 + const raw = c.req.query('handle'); 374 + if (!raw) return errorPage(c, 'Missing handle.'); 375 + const handle = normalizeHandle(raw); 357 376 const state = randomBytes(16).toString('hex'); 358 - const url = await oauth.authorize(handle, { state }); 359 - return c.redirect(url.toString()); 377 + try { 378 + const url = await oauth.authorize(handle, { state }); 379 + return c.redirect(url.toString()); 380 + } catch (err) { 381 + console.error('oauth authorize failed', err); 382 + return errorPage(c, `Could not resolve &ldquo;${handle}&rdquo;. Enter a handle like <code>you.bsky.social</code> or a DID.`); 383 + } 360 384 }); 361 385 362 386 app.get('/oauth/callback', async (c) => {