this repo has no description
1
fork

Configure Feed

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

tldr, yay #1

open opened by yzzxyz.roomy.chat targeting main from yzzxyz.roomy.chat/portable-agency: main
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:5lewiq2q3rliu3ypkx57zasr/sh.tangled.repo.pull/3mkgkk3yyfe22
+147 -94
Diff #0
+13 -4
README.md
··· 10 10 11 11 ## How this works 12 12 13 - Each linkage is a *pair of records* on atproto PDSes — 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. 13 + ### TL;DR Version 14 + 15 + - lets someone login to a web page with their Discord account 16 + - the page makes a call to Discord API, checking membership here or not 17 + - if member, it writes that to the checker's PDS and to the user's PDS 18 + - if not a member, stops without writing anything anywhere 19 + 20 + ### Detailed Version 21 + 22 + Each linkage is a _pair of records_ on atproto PDSes — 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. 14 23 15 24 1. **Link a platformed account.** Authorize the external service (e.g. Discord) so we can confirm your membership and any relevant role. Nothing is written yet. 16 25 2. **Sign in with your Atmosphere account.** Fine-grained OAuth — we only request permission to write to the `agency.portable.membership` collection. 17 26 3. **Two records are written.** 18 - - An **attestation** (`agency.portable.attestation`) on portable.agency's PDS — a third-party statement that your DID owns the linked account. 19 - - A **claim** (`agency.portable.membership`) on your own PDS — a self-claim naming portable.agency as the attester. 27 + - An **attestation** (`agency.portable.attestation`) on portable.agency's PDS — a third-party statement that your DID owns the linked account. 28 + - A **claim** (`agency.portable.membership`) on your own PDS — a self-claim naming portable.agency as the attester. 20 29 21 - Both records carry the same `service` block. Matching them is the proof. 30 + Both records carry the same `service` block. Matching them is the proof. 22 31 23 32 **Multiple linkages.** Record keys are deterministic (hash of `did + service.type + community + identifier`), so re-linking the same external account is idempotent; linking a different account (e.g. a second Discord alt) creates a separate record. You can have N linkages per platform. 24 33
+134 -90
src/server.js
··· 1 - import 'dotenv/config'; 2 - import { readFile } from 'node:fs/promises'; 3 - import { randomBytes } from 'node:crypto'; 4 - import { fileURLToPath } from 'node:url'; 5 - import { dirname, join } from 'node:path'; 6 - import { Hono } from 'hono'; 7 - import { serve } from '@hono/node-server'; 8 - import { Agent } from '@atproto/api'; 9 - import { buildOAuthClient } from './oauth.js'; 10 - import { writeAttestation, deleteAttestation } from './attester.js'; 11 - import { membershipRkey, attestationRkey } from './rkey.js'; 12 - import * as discord from './discord.js'; 13 - import { setSession, getSession, clearSession } from './cookies.js'; 1 + import "dotenv/config"; 2 + import { readFile } from "node:fs/promises"; 3 + import { randomBytes } from "node:crypto"; 4 + import { fileURLToPath } from "node:url"; 5 + import { dirname, join } from "node:path"; 6 + import { Hono } from "hono"; 7 + import { serve } from "@hono/node-server"; 8 + import { Agent } from "@atproto/api"; 9 + import { buildOAuthClient } from "./oauth.js"; 10 + import { writeAttestation, deleteAttestation } from "./attester.js"; 11 + import { membershipRkey, attestationRkey } from "./rkey.js"; 12 + import * as discord from "./discord.js"; 13 + import { setSession, getSession, clearSession } from "./cookies.js"; 14 14 15 15 const __dirname = dirname(fileURLToPath(import.meta.url)); 16 - const LEXICONS_DIR = join(__dirname, '..', 'lexicons'); 16 + const LEXICONS_DIR = join(__dirname, "..", "lexicons"); 17 17 18 18 const PUBLIC_URL = process.env.PUBLIC_URL; 19 19 const SESSION_SECRET = process.env.SESSION_SECRET; ··· 25 25 26 26 const oauth = await buildOAuthClient({ publicUrl: PUBLIC_URL }); 27 27 28 - const errorPage = (c, message) => c.html(` 28 + const errorPage = (c, message) => 29 + c.html( 30 + ` 29 31 <!doctype html> 30 32 <meta charset="utf-8"> 31 33 <title>something went wrong</title> 32 34 <h1>something went wrong</h1> 33 35 <p>${message}</p> 34 36 <p><a href="/">Start over</a></p> 35 - `, 400); 37 + `, 38 + 400, 39 + ); 36 40 37 41 const app = new Hono(); 38 42 39 - app.get('/', (c) => { 43 + app.get("/", (c) => { 40 44 const s = getSession(c, SESSION_SECRET); 41 45 const discordInFlight = !!s?.discord; 42 46 43 - const discordCard = discordInFlight ? ` 47 + const discordCard = discordInFlight 48 + ? ` 44 49 <ul> 45 50 <li>User: <code>${s.discord.username ?? s.discord.userId}</code></li> 46 51 <li>Guild: <code>User &amp; Agents</code></li> 47 - ${s.discord.role ? `<li>Role: <code>${s.discord.role}</code></li>` : ''} 52 + ${s.discord.role ? `<li>Role: <code>${s.discord.role}</code></li>` : ""} 48 53 </ul> 49 54 <form action="/cancel" method="post"><button type="submit">Cancel</button></form> 50 - ` : ` 55 + ` 56 + : ` 51 57 <p><a href="/discord/start"><button type="button">Link Discord account</button></a></p> 52 58 `; 53 59 54 - const atmosphereCard = discordInFlight ? ` 60 + const atmosphereCard = discordInFlight 61 + ? ` 55 62 <form action="/login" method="get"> 56 63 <input name="handle" placeholder="you.bsky.social" required> 57 64 <button type="submit">Sign in</button> 58 65 </form> 59 66 <p><small>Signing in writes the link and completes the flow.</small></p> 60 - ` : ` 67 + ` 68 + : ` 61 69 <form action="/login" method="get"> 62 70 <input name="handle" placeholder="you.bsky.social" disabled> 63 71 <button type="submit" disabled>Sign in</button> ··· 119 127 </style> 120 128 <h1>portable.agency</h1> 121 129 <p>Link your platformed accounts to an Atmosphere account.</p> 122 - <div id="rows" class="rows" data-midflow="${discordInFlight ? '1' : ''}"> 130 + <div id="rows" class="rows" data-midflow="${discordInFlight ? "1" : ""}"> 123 131 ${serverRow} 124 132 </div> 125 133 <script> ··· 131 139 </script> 132 140 <section class="how"> 133 141 <h2>How this works</h2> 142 + <h3>TL;DR Version<h3> 143 + <ul> 144 + <li>lets someone login to a web page with their Discord account</li> 145 + <li>the page makes a call to Discord API, checking membership here or not</li> 146 + <li>if member, it writes that to the checker's PDS and to the user's PDS</li> 147 + <li>if not a member, stops without writing anything anywhere</li> 148 + </ul> 149 + <h3>Detailed Version</h3> 134 150 <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> 135 151 <ol> 136 152 <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> ··· 277 293 `); 278 294 }); 279 295 280 - app.post('/cancel', (c) => { 296 + app.post("/cancel", (c) => { 281 297 clearSession(c); 282 - return c.redirect('/'); 298 + return c.redirect("/"); 283 299 }); 284 300 285 - app.post('/unlink', async (c) => { 301 + app.post("/unlink", async (c) => { 286 302 const form = await c.req.formData(); 287 - const did = form.get('did'); 288 - const type = form.get('type'); 289 - const community = form.get('community') || undefined; 290 - const identifier = form.get('identifier') || undefined; 291 - if (!did || !type) return errorPage(c, 'Missing unlink parameters.'); 292 - const service = { type, ...(community ? { community } : {}), ...(identifier ? { identifier } : {}) }; 293 - const state = randomBytes(16).toString('hex'); 303 + const did = form.get("did"); 304 + const type = form.get("type"); 305 + const community = form.get("community") || undefined; 306 + const identifier = form.get("identifier") || undefined; 307 + if (!did || !type) return errorPage(c, "Missing unlink parameters."); 308 + const service = { 309 + type, 310 + ...(community ? { community } : {}), 311 + ...(identifier ? { identifier } : {}), 312 + }; 313 + const state = randomBytes(16).toString("hex"); 294 314 setSession(c, { unlink: { did, service } }, SESSION_SECRET); 295 315 try { 296 316 const url = await oauth.authorize(did, { state }); 297 317 return c.redirect(url.toString()); 298 318 } catch (err) { 299 - console.error('unlink authorize failed', err); 319 + console.error("unlink authorize failed", err); 300 320 clearSession(c); 301 - return errorPage(c, 'Could not start sign-in to unlink.'); 321 + return errorPage(c, "Could not start sign-in to unlink."); 302 322 } 303 323 }); 304 324 305 - app.get('/client-metadata.json', (c) => c.json(oauth.clientMetadata)); 325 + app.get("/client-metadata.json", (c) => c.json(oauth.clientMetadata)); 306 326 307 - app.get('/jwks.json', (c) => c.json(oauth.jwks)); 327 + app.get("/jwks.json", (c) => c.json(oauth.jwks)); 308 328 309 - app.get('/lexicons/:nsid', async (c) => { 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); 329 + app.get("/lexicons/:nsid", async (c) => { 330 + const nsid = c.req.param("nsid").replace(/\.json$/, ""); 331 + if (!/^[a-z][a-z0-9]*(\.[a-z0-9-]+)+$/i.test(nsid)) 332 + return c.text("not found", 404); 312 333 try { 313 - const body = await readFile(join(LEXICONS_DIR, `${nsid}.json`), 'utf8'); 314 - return c.body(body, 200, { 'content-type': 'application/json' }); 334 + const body = await readFile(join(LEXICONS_DIR, `${nsid}.json`), "utf8"); 335 + return c.body(body, 200, { "content-type": "application/json" }); 315 336 } catch { 316 - return c.text('not found', 404); 337 + return c.text("not found", 404); 317 338 } 318 339 }); 319 340 320 - app.get('/discord/start', (c) => { 321 - const discordState = randomBytes(16).toString('hex'); 341 + app.get("/discord/start", (c) => { 342 + const discordState = randomBytes(16).toString("hex"); 322 343 setSession(c, { discordState }, SESSION_SECRET); 323 - return c.redirect(discord.authUrl({ state: discordState, redirectUri: DISCORD_REDIRECT })); 344 + return c.redirect( 345 + discord.authUrl({ state: discordState, redirectUri: DISCORD_REDIRECT }), 346 + ); 324 347 }); 325 348 326 - app.get('/discord/callback', async (c) => { 349 + app.get("/discord/callback", async (c) => { 327 350 const s = getSession(c, SESSION_SECRET); 328 - const code = c.req.query('code'); 329 - const state = c.req.query('state'); 330 - if (!code) return errorPage(c, 'Missing Discord authorization code.'); 351 + const code = c.req.query("code"); 352 + const state = c.req.query("state"); 353 + if (!code) return errorPage(c, "Missing Discord authorization code."); 331 354 if (!s?.discordState || state !== s.discordState) { 332 - return errorPage(c, 'Discord state mismatch — possible CSRF.'); 355 + return errorPage(c, "Discord state mismatch — possible CSRF."); 333 356 } 334 357 335 358 let user, member; 336 359 try { 337 - const token = await discord.exchangeCode({ code, redirectUri: DISCORD_REDIRECT }); 360 + const token = await discord.exchangeCode({ 361 + code, 362 + redirectUri: DISCORD_REDIRECT, 363 + }); 338 364 user = await discord.getUser(token.access_token); 339 365 member = await discord.getGuildMember(token.access_token, UA_GUILD_ID); 340 366 } catch (err) { 341 - console.error('discord oauth failed', err); 342 - return errorPage(c, 'Could not verify Discord account.'); 367 + console.error("discord oauth failed", err); 368 + return errorPage(c, "Could not verify Discord account."); 343 369 } 344 370 345 - if (!member) return errorPage(c, 'You are not a member of the User &amp; Agents Discord.'); 346 - 347 - const role = (member.roles ?? []).includes(UA_FASCINATOR_ROLE_ID) ? 'fascinator' : undefined; 348 - setSession(c, { 349 - discord: { userId: user.id, username: user.username, role }, 350 - }, SESSION_SECRET); 351 - return c.redirect('/'); 371 + if (!member) 372 + return errorPage( 373 + c, 374 + "You are not a member of the User &amp; Agents Discord.", 375 + ); 376 + 377 + const role = (member.roles ?? []).includes(UA_FASCINATOR_ROLE_ID) 378 + ? "fascinator" 379 + : undefined; 380 + setSession( 381 + c, 382 + { 383 + discord: { userId: user.id, username: user.username, role }, 384 + }, 385 + SESSION_SECRET, 386 + ); 387 + return c.redirect("/"); 352 388 }); 353 389 354 390 const normalizeHandle = (raw) => { 355 391 let h = raw.trim(); 356 - if (h.startsWith('@')) h = h.slice(1); 357 - if (h.includes('/')) { 392 + if (h.startsWith("@")) h = h.slice(1); 393 + if (h.includes("/")) { 358 394 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); 395 + const u = new URL(h.includes("://") ? h : `https://${h}`); 396 + if (u.pathname.startsWith("/profile/")) { 397 + h = decodeURIComponent( 398 + u.pathname.slice("/profile/".length).split("/")[0], 399 + ); 400 + if (h.startsWith("@")) h = h.slice(1); 363 401 } 364 402 } catch {} 365 403 } 366 - if (h.endsWith('.')) h = h.slice(0, -1); 404 + if (h.endsWith(".")) h = h.slice(0, -1); 367 405 return h; 368 406 }; 369 407 370 - app.get('/login', async (c) => { 408 + app.get("/login", async (c) => { 371 409 const s = getSession(c, SESSION_SECRET); 372 - if (!s?.discord) return c.redirect('/'); 373 - const raw = c.req.query('handle'); 374 - if (!raw) return errorPage(c, 'Missing handle.'); 410 + if (!s?.discord) return c.redirect("/"); 411 + const raw = c.req.query("handle"); 412 + if (!raw) return errorPage(c, "Missing handle."); 375 413 const handle = normalizeHandle(raw); 376 - const state = randomBytes(16).toString('hex'); 414 + const state = randomBytes(16).toString("hex"); 377 415 try { 378 416 const url = await oauth.authorize(handle, { state }); 379 417 return c.redirect(url.toString()); 380 418 } 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.`); 419 + console.error("oauth authorize failed", err); 420 + return errorPage( 421 + c, 422 + `Could not resolve &ldquo;${handle}&rdquo;. Enter a handle like <code>you.bsky.social</code> or a DID.`, 423 + ); 383 424 } 384 425 }); 385 426 386 - app.get('/oauth/callback', async (c) => { 427 + app.get("/oauth/callback", async (c) => { 387 428 const s = getSession(c, SESSION_SECRET); 388 429 if (!s?.discord && !s?.unlink) { 389 430 clearSession(c); 390 - return errorPage(c, 'Missing flow state. Start over.'); 431 + return errorPage(c, "Missing flow state. Start over."); 391 432 } 392 433 393 434 let session; 394 435 try { 395 - const params = new URLSearchParams(c.req.url.split('?')[1]); 436 + const params = new URLSearchParams(c.req.url.split("?")[1]); 396 437 ({ session } = await oauth.callback(params)); 397 438 } catch (err) { 398 - console.error('bsky oauth callback failed', err); 439 + console.error("bsky oauth callback failed", err); 399 440 clearSession(c); 400 - return errorPage(c, 'Sign-in with atmosphere account failed. Try again.'); 441 + return errorPage(c, "Sign-in with atmosphere account failed. Try again."); 401 442 } 402 443 403 444 if (s.unlink) { 404 445 if (session.did !== s.unlink.did) { 405 446 clearSession(c); 406 - return errorPage(c, 'Signed-in DID does not match the linkage you asked to unlink.'); 447 + return errorPage( 448 + c, 449 + "Signed-in DID does not match the linkage you asked to unlink.", 450 + ); 407 451 } 408 452 const service = s.unlink.service; 409 453 const attRkey = attestationRkey({ subject: session.did, service }); ··· 411 455 try { 412 456 await deleteAttestation({ rkey: attRkey }); 413 457 } catch (err) { 414 - console.error('attestation delete failed', err); 458 + console.error("attestation delete failed", err); 415 459 } 416 460 try { 417 461 const bskyAgent = new Agent(session); 418 462 await bskyAgent.com.atproto.repo.deleteRecord({ 419 463 repo: session.did, 420 - collection: 'agency.portable.membership', 464 + collection: "agency.portable.membership", 421 465 rkey: memRkey, 422 466 }); 423 467 } catch (err) { 424 - console.error('membership delete failed', err); 468 + console.error("membership delete failed", err); 425 469 } 426 470 clearSession(c); 427 - return c.redirect('/'); 471 + return c.redirect("/"); 428 472 } 429 473 430 474 const service = { 431 - type: 'discord', 475 + type: "discord", 432 476 community: UA_GUILD_ID, 433 477 identifier: s.discord.userId, 434 478 }; ··· 441 485 const bskyAgent = new Agent(session); 442 486 await bskyAgent.com.atproto.repo.putRecord({ 443 487 repo: session.did, 444 - collection: 'agency.portable.membership', 488 + collection: "agency.portable.membership", 445 489 rkey: memRkey, 446 490 record: { 447 - $type: 'agency.portable.membership', 491 + $type: "agency.portable.membership", 448 492 service, 449 493 ...(role ? { role } : {}), 450 494 attestedBy: ATTESTER_DID, ··· 452 496 }, 453 497 }); 454 498 } catch (err) { 455 - console.error('record write failed', err); 499 + console.error("record write failed", err); 456 500 clearSession(c); 457 - return errorPage(c, 'Could not write linkage records. Please try again.'); 501 + return errorPage(c, "Could not write linkage records. Please try again."); 458 502 } 459 503 460 504 clearSession(c);

History

2 rounds 0 comments
sign up or login to add to the discussion
2 commits
expand
tdlr, smol tweaks to server. feel free to disregard the linter changes sry
Remove auto-formatting changes
no conflicts, ready to merge
expand 0 comments
yzzxyz.roomy.chat submitted #0
1 commit
expand
tdlr, smol tweaks to server. feel free to disregard the linter changes sry
expand 0 comments