+147
-94
Diff
round #0
+13
-4
README.md
+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
+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 & 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 — 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 & 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 & 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 “${handle}”. 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 “${handle}”. 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
yzzxyz.roomy.chat
submitted
#1
2 commits
expand
collapse
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
collapse
tdlr, smol tweaks to server. feel free to disregard the linter changes sry