Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

Add "Handle Identity on the AT Protocol" paper + standard site sync

New 6-page paper (arxiv-identity) surveying ATProto identity stack, pckt.blog's
decentralized auth model, and a phased migration plan from Auth0 to ATProto OAuth
for Aesthetic Computer. Includes reference links for all atproto specs, OAuth RFCs,
npm packages, and example repos.

Also includes AT CLI sync:standard command and standard-site backfill script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1416 -8
+7
.githooks/post-commit
··· 28 28 fish "$REPO_ROOT/feed/deploy-silo.fish" --landing 2>&1 | tail -3 & 29 29 fi 30 30 31 + # Auto-deploy AT PDS frontend when tracked files change 32 + AT_FRONTEND_FILES="at/index.html|at/user-page.html|at/media-modal.js|at/media-records.js" 33 + if git diff-tree --no-commit-id --name-only -r HEAD | grep -qE "^($AT_FRONTEND_FILES)$"; then 34 + echo "🔮 AT frontend changed — deploying to PDS..." 35 + bash "$REPO_ROOT/at/scripts/deploy-at-frontend.sh" 2>&1 | tail -3 & 36 + fi 37 + 31 38 exit 0
+51
at/STANDARD-SITE-SYNC.md
··· 1 + # Standard.site Mirror Sync 2 + 3 + Shallow-copy selected `computer.aesthetic.*` ATProto records into `site.standard.document`. 4 + 5 + ## Recommended First-Wave Sources 6 + 7 + - `computer.aesthetic.paper` → high-fidelity long-form records (`papers.aesthetic.computer`) 8 + - `computer.aesthetic.news` → headline/body documents (`news.aesthetic.computer`) 9 + - `computer.aesthetic.piece` → canonical piece pages (`aesthetic.computer/<slug>`) 10 + 11 + These are the default sources in the script. 12 + 13 + ## Script 14 + 15 + `at/scripts/atproto/backfill-standard-site-documents.mjs` 16 + 17 + This script: 18 + 19 + - Logs into each user repo with existing ATProto credentials 20 + - Reads selected source records 21 + - Creates `site.standard.document` records with mapped fields 22 + - Deduplicates by `sourceAtUri` (stored on target records) 23 + - Never modifies source records (shallow copy) 24 + 25 + ## Usage 26 + 27 + ```bash 28 + # Dry run (recommended first) 29 + npm run at:standard:dry 30 + 31 + # Live sync (default sources: paper,news,piece) 32 + npm run at:standard:sync 33 + 34 + # Single user 35 + node at/scripts/atproto/backfill-standard-site-documents.mjs --dry-run --user @jeffrey 36 + 37 + # Custom sources 38 + node at/scripts/atproto/backfill-standard-site-documents.mjs --dry-run --sources=paper,news,piece,kidlisp 39 + 40 + # Limit records per source per user 41 + node at/scripts/atproto/backfill-standard-site-documents.mjs --dry-run --limit 25 42 + 43 + # Limit number of users processed (staged rollout) 44 + node at/scripts/atproto/backfill-standard-site-documents.mjs --dry-run --user-limit 10 45 + ``` 46 + 47 + ## Notes 48 + 49 + - Target collection: `site.standard.document` 50 + - Required target fields are always populated: `site`, `title`, `publishedAt` 51 + - Optional mappings include `path`, `description`, `textContent`, and `tags`
+23
at/cli.mjs
··· 490 490 console.log(); 491 491 } 492 492 493 + async function commandSyncStandard() { 494 + const { execFileSync } = await import("child_process"); 495 + const { fileURLToPath } = await import("url"); 496 + 497 + const scriptUrl = new URL( 498 + "./scripts/atproto/backfill-standard-site-documents.mjs", 499 + import.meta.url, 500 + ); 501 + const scriptPath = fileURLToPath(scriptUrl); 502 + const passthroughArgs = process.argv.slice(3); 503 + 504 + try { 505 + execFileSync("node", [scriptPath, ...passthroughArgs], { 506 + stdio: "inherit", 507 + }); 508 + } catch (error) { 509 + process.exitCode = error.status || 1; 510 + } 511 + } 512 + 493 513 async function commandSSH(args) { 494 514 const { execSync } = await import("child_process"); 495 515 const ip = process.env.PDS_SSH_HOST || "165.227.120.137"; ··· 563 583 accounts [--limit=N] List PDS accounts 564 584 account:check <handle-or-did> Inspect account & record counts 565 585 sync:status Record counts across collections 586 + sync:standard [options] Mirror AC records to site.standard.document 566 587 567 588 Server: 568 589 ssh [command] SSH into PDS droplet (or run command) ··· 585 606 ac-at invite 586 607 ac-at account:check jeffrey.at.aesthetic.computer 587 608 ac-at sync:status 609 + ac-at sync:standard --dry-run --sources=paper,news,piece --limit=25 588 610 `); 589 611 } 590 612 ··· 604 626 accounts: commandAccounts, 605 627 "account:check": commandAccountCheck, 606 628 "sync:status": commandSyncStatus, 629 + "sync:standard": commandSyncStandard, 607 630 ssh: commandSSH, 608 631 "env:set": commandEnvSet, 609 632 };
+526
at/scripts/atproto/backfill-standard-site-documents.mjs
··· 1 + #!/usr/bin/env node 2 + 3 + /** 4 + * Backfill Standard.site Documents 5 + * 6 + * Shallow-copies existing Aesthetic Computer ATProto records into 7 + * `site.standard.document` records. 8 + * 9 + * Default source collections: 10 + * - computer.aesthetic.paper 11 + * - computer.aesthetic.news 12 + * - computer.aesthetic.piece 13 + * 14 + * Usage: 15 + * node at/scripts/atproto/backfill-standard-site-documents.mjs [options] 16 + * 17 + * Options: 18 + * --dry-run Show what would be synced without creating records 19 + * --user @handle Only process a single user's repo 20 + * --user-limit N Only process first N users (for testing/staged rollout) 21 + * --sources list Comma-separated: paper,news,piece,kidlisp,mood 22 + * --limit N Max source records per source collection (per user) 23 + * --batch-size N Pause every N creates (default: 20) 24 + * --delay MS Pause length in milliseconds (default: 300) 25 + */ 26 + 27 + import { AtpAgent } from "@atproto/api"; 28 + import { connect } from "../../../system/backend/database.mjs"; 29 + import { config } from "dotenv"; 30 + 31 + config({ path: "../../../system/.env" }); 32 + 33 + const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer"; 34 + const TARGET_COLLECTION = "site.standard.document"; 35 + const MAX_PAGE_SIZE = 100; 36 + 37 + const SOURCE_CONFIG = { 38 + paper: { 39 + collection: "computer.aesthetic.paper", 40 + toDocument(sourceRecord) { 41 + const value = sourceRecord.value || {}; 42 + const rkey = rkeyFromUri(sourceRecord.uri); 43 + const slugRaw = String(value.slug || value.ref || rkey || "").trim(); 44 + const slug = slugRaw.replace(/^\/+/, ""); 45 + const path = slug 46 + ? slug.endsWith(".pdf") 47 + ? `/${slug}` 48 + : `/${slug}.pdf` 49 + : `/paper/${rkey || Date.now().toString(36)}`; 50 + const title = truncate(String(value.title || slug || "Untitled Paper"), 5000); 51 + 52 + return { 53 + site: "https://papers.aesthetic.computer", 54 + path, 55 + title, 56 + description: truncate( 57 + `Paper from Aesthetic Computer${value.languages?.length ? ` (${value.languages.join(", ")})` : ""}`, 58 + 3000, 59 + ), 60 + tags: ["paper", "aesthetic-computer"], 61 + publishedAt: toIsoString(value.when), 62 + }; 63 + }, 64 + }, 65 + news: { 66 + collection: "computer.aesthetic.news", 67 + toDocument(sourceRecord) { 68 + const value = sourceRecord.value || {}; 69 + const rkey = rkeyFromUri(sourceRecord.uri); 70 + const site = "https://news.aesthetic.computer"; 71 + const pathFromLink = pathFromUrlIfSameBase(value.link, site); 72 + const path = pathFromLink || `/atproto/${rkey || Date.now().toString(36)}`; 73 + 74 + const headline = String(value.headline || "").trim(); 75 + if (!headline) return null; 76 + 77 + const body = String(value.body || "").trim(); 78 + const tags = Array.isArray(value.tags) 79 + ? value.tags 80 + .map((tag) => String(tag || "").trim()) 81 + .filter(Boolean) 82 + .slice(0, 16) 83 + : []; 84 + 85 + return { 86 + site, 87 + path, 88 + title: truncate(headline, 5000), 89 + description: truncate(body || `News update: ${headline}`, 3000), 90 + textContent: body || undefined, 91 + tags, 92 + publishedAt: toIsoString(value.when), 93 + }; 94 + }, 95 + }, 96 + piece: { 97 + collection: "computer.aesthetic.piece", 98 + toDocument(sourceRecord) { 99 + const value = sourceRecord.value || {}; 100 + const slug = String(value.slug || "").trim(); 101 + if (!slug) return null; 102 + 103 + return { 104 + site: "https://aesthetic.computer", 105 + path: `/${slug}`, 106 + title: truncate(slug, 5000), 107 + description: truncate(`Interactive piece: ${slug}`, 3000), 108 + tags: ["piece", "interactive"], 109 + publishedAt: toIsoString(value.when), 110 + }; 111 + }, 112 + }, 113 + kidlisp: { 114 + collection: "computer.aesthetic.kidlisp", 115 + toDocument(sourceRecord) { 116 + const value = sourceRecord.value || {}; 117 + const code = String(value.code || "").trim(); 118 + if (!code) return null; 119 + 120 + const source = String(value.source || "").trim(); 121 + return { 122 + site: "https://aesthetic.computer", 123 + path: `/$${code}`, 124 + title: truncate(`KidLisp ${code}`, 5000), 125 + description: truncate(`KidLisp program ${code}`, 3000), 126 + textContent: source || undefined, 127 + tags: ["kidlisp", "code"], 128 + publishedAt: toIsoString(value.when), 129 + }; 130 + }, 131 + }, 132 + mood: { 133 + collection: "computer.aesthetic.mood", 134 + toDocument(sourceRecord) { 135 + const value = sourceRecord.value || {}; 136 + const mood = String(value.mood || "").trim(); 137 + if (!mood) return null; 138 + const rkey = rkeyFromUri(sourceRecord.uri); 139 + const title = truncate(mood.slice(0, 120), 5000); 140 + return { 141 + site: "https://aesthetic.computer", 142 + path: `/mood/${rkey || Date.now().toString(36)}`, 143 + title, 144 + description: truncate(mood, 3000), 145 + textContent: mood, 146 + tags: ["mood"], 147 + publishedAt: toIsoString(value.when), 148 + }; 149 + }, 150 + }, 151 + }; 152 + 153 + function parseArgs(argv) { 154 + const out = { _: [] }; 155 + for (let i = 0; i < argv.length; i++) { 156 + const token = argv[i]; 157 + if (!token.startsWith("--")) { 158 + out._.push(token); 159 + continue; 160 + } 161 + const eq = token.indexOf("="); 162 + if (eq !== -1) { 163 + out[token.slice(2, eq)] = token.slice(eq + 1); 164 + continue; 165 + } 166 + const next = argv[i + 1]; 167 + if (next && !next.startsWith("--")) { 168 + out[token.slice(2)] = next; 169 + i++; 170 + } else { 171 + out[token.slice(2)] = true; 172 + } 173 + } 174 + return out; 175 + } 176 + 177 + function toIsoString(value) { 178 + const date = value instanceof Date ? value : new Date(value || Date.now()); 179 + if (!Number.isNaN(date.getTime())) return date.toISOString(); 180 + return new Date().toISOString(); 181 + } 182 + 183 + function truncate(value, max) { 184 + const str = String(value || ""); 185 + if (!max || str.length <= max) return str; 186 + return str.slice(0, max); 187 + } 188 + 189 + function trimTrailingSlash(value) { 190 + return String(value || "").replace(/\/+$/, ""); 191 + } 192 + 193 + function pathFromUrlIfSameBase(candidateUrl, baseUrl) { 194 + if (!candidateUrl) return null; 195 + try { 196 + const base = new URL(trimTrailingSlash(baseUrl)); 197 + const candidate = new URL(String(candidateUrl)); 198 + if (candidate.origin !== base.origin) return null; 199 + const normalizedBasePath = trimTrailingSlash(base.pathname || ""); 200 + const normalizedCandidatePath = candidate.pathname || "/"; 201 + 202 + if ( 203 + normalizedBasePath && 204 + normalizedBasePath !== "/" && 205 + !normalizedCandidatePath.startsWith(normalizedBasePath) 206 + ) { 207 + return null; 208 + } 209 + 210 + const pathname = normalizedCandidatePath.startsWith("/") 211 + ? normalizedCandidatePath 212 + : `/${normalizedCandidatePath}`; 213 + return `${pathname}${candidate.search || ""}${candidate.hash || ""}`; 214 + } catch { 215 + return null; 216 + } 217 + } 218 + 219 + function rkeyFromUri(uri) { 220 + const str = String(uri || ""); 221 + if (!str.includes("/")) return ""; 222 + return str.split("/").pop() || ""; 223 + } 224 + 225 + async function sleep(ms) { 226 + return new Promise((resolve) => setTimeout(resolve, ms)); 227 + } 228 + 229 + async function listAllRecords(agent, repo, collection, limit = null) { 230 + const records = []; 231 + let cursor; 232 + 233 + while (true) { 234 + const remaining = limit == null ? MAX_PAGE_SIZE : Math.max(limit - records.length, 0); 235 + if (remaining <= 0) break; 236 + 237 + const pageLimit = Math.min(MAX_PAGE_SIZE, remaining); 238 + const response = await agent.com.atproto.repo.listRecords({ 239 + repo, 240 + collection, 241 + limit: pageLimit, 242 + cursor, 243 + }); 244 + 245 + const batch = response.data?.records || []; 246 + records.push(...batch); 247 + 248 + cursor = response.data?.cursor; 249 + if (!cursor || batch.length === 0) break; 250 + } 251 + 252 + return records; 253 + } 254 + 255 + async function loadTargetUsers(database, handle, userLimit = null) { 256 + const users = database.db.collection("users"); 257 + const handles = database.db.collection("@handles"); 258 + 259 + if (handle) { 260 + const clean = handle.replace(/^@/, ""); 261 + const handleDoc = await handles.findOne({ handle: clean }); 262 + if (!handleDoc) { 263 + throw new Error(`User @${clean} not found`); 264 + } 265 + const user = await users.findOne({ _id: handleDoc._id }); 266 + if (!user?.atproto?.did || !user?.atproto?.password) { 267 + throw new Error(`User @${clean} has no ATProto credentials`); 268 + } 269 + return [ 270 + { 271 + sub: user._id, 272 + handle: clean, 273 + did: user.atproto.did, 274 + password: user.atproto.password, 275 + }, 276 + ]; 277 + } 278 + 279 + const userDocs = await users 280 + .find({ 281 + "atproto.did": { $exists: true, $ne: null }, 282 + "atproto.password": { $exists: true, $ne: null }, 283 + }) 284 + .project({ _id: 1, atproto: 1 }) 285 + .toArray(); 286 + 287 + const byId = new Map(); 288 + const handleDocs = await handles 289 + .find({ _id: { $in: userDocs.map((u) => u._id) } }) 290 + .project({ _id: 1, handle: 1 }) 291 + .toArray(); 292 + 293 + for (const doc of handleDocs) { 294 + byId.set(String(doc._id), doc.handle || "unknown"); 295 + } 296 + 297 + const allUsers = userDocs.map((user) => ({ 298 + sub: user._id, 299 + handle: byId.get(String(user._id)) || "unknown", 300 + did: user.atproto.did, 301 + password: user.atproto.password, 302 + })); 303 + 304 + if (userLimit == null || Number.isNaN(userLimit) || userLimit <= 0) { 305 + return allUsers; 306 + } 307 + 308 + return allUsers.slice(0, userLimit); 309 + } 310 + 311 + function parseSources(arg) { 312 + if (!arg) return ["paper", "news", "piece"]; 313 + return String(arg) 314 + .split(",") 315 + .map((source) => source.trim().toLowerCase()) 316 + .filter(Boolean); 317 + } 318 + 319 + function ensureSourcesValid(sourceNames) { 320 + const invalid = sourceNames.filter((name) => !SOURCE_CONFIG[name]); 321 + if (invalid.length > 0) { 322 + throw new Error( 323 + `Unknown sources: ${invalid.join(", ")}. Valid sources: ${Object.keys(SOURCE_CONFIG).join(", ")}`, 324 + ); 325 + } 326 + } 327 + 328 + async function main() { 329 + const args = parseArgs(process.argv.slice(2)); 330 + const dryRun = Boolean(args["dry-run"]); 331 + const targetHandle = args.user ? String(args.user) : null; 332 + const userLimit = args["user-limit"] ? parseInt(args["user-limit"], 10) : null; 333 + const sources = parseSources(args.sources); 334 + const limit = args.limit ? parseInt(args.limit, 10) : null; 335 + const batchSize = args["batch-size"] ? parseInt(args["batch-size"], 10) : 20; 336 + const delayMs = args.delay ? parseInt(args.delay, 10) : 300; 337 + 338 + ensureSourcesValid(sources); 339 + 340 + console.log("\n🧬 Backfill Standard.site Documents\n"); 341 + console.log(`PDS: ${PDS_URL}`); 342 + console.log(`Mode: ${dryRun ? "🔍 DRY RUN" : "✍️ LIVE"}`); 343 + if (targetHandle) console.log(`User: ${targetHandle}`); 344 + if (!targetHandle && userLimit != null && !Number.isNaN(userLimit)) { 345 + console.log(`User limit: ${userLimit}`); 346 + } 347 + console.log(`Sources: ${sources.join(", ")}`); 348 + if (limit != null && !Number.isNaN(limit)) console.log(`Limit/source: ${limit}`); 349 + console.log(`Batch size: ${batchSize}`); 350 + console.log(`Delay: ${delayMs}ms\n`); 351 + 352 + const database = await connect(); 353 + 354 + let usersToProcess; 355 + try { 356 + usersToProcess = await loadTargetUsers(database, targetHandle, userLimit); 357 + } catch (error) { 358 + await database.disconnect(); 359 + throw error; 360 + } 361 + 362 + if (usersToProcess.length === 0) { 363 + console.log("No users with ATProto credentials found."); 364 + await database.disconnect(); 365 + return; 366 + } 367 + 368 + const totals = { 369 + users: usersToProcess.length, 370 + created: 0, 371 + skipped: 0, 372 + failed: 0, 373 + }; 374 + 375 + const bySource = Object.fromEntries( 376 + sources.map((source) => [source, { created: 0, skipped: 0, failed: 0, scanned: 0 }]), 377 + ); 378 + 379 + for (let u = 0; u < usersToProcess.length; u++) { 380 + const user = usersToProcess[u]; 381 + console.log(`\n[${u + 1}/${usersToProcess.length}] @${user.handle} (${user.did})`); 382 + 383 + const agent = new AtpAgent({ service: PDS_URL }); 384 + try { 385 + await agent.login({ identifier: user.did, password: user.password }); 386 + } catch (error) { 387 + console.log(` ❌ Login failed: ${error.message}`); 388 + totals.failed += 1; 389 + continue; 390 + } 391 + 392 + let existingDocs = []; 393 + try { 394 + existingDocs = await listAllRecords(agent, user.did, TARGET_COLLECTION, null); 395 + } catch { 396 + existingDocs = []; 397 + } 398 + 399 + const existingBySourceUri = new Set( 400 + existingDocs 401 + .map((record) => String(record.value?.sourceAtUri || "").trim()) 402 + .filter(Boolean), 403 + ); 404 + 405 + console.log(` Existing ${TARGET_COLLECTION}: ${existingDocs.length}`); 406 + 407 + for (const sourceName of sources) { 408 + const source = SOURCE_CONFIG[sourceName]; 409 + 410 + let sourceRecords = []; 411 + try { 412 + sourceRecords = await listAllRecords(agent, user.did, source.collection, limit); 413 + } catch { 414 + sourceRecords = []; 415 + } 416 + 417 + if (sourceRecords.length === 0) { 418 + console.log(` ${sourceName.padEnd(8)} 0 source records`); 419 + continue; 420 + } 421 + 422 + console.log(` ${sourceName.padEnd(8)} scanning ${sourceRecords.length} records`); 423 + 424 + for (let i = 0; i < sourceRecords.length; i++) { 425 + const sourceRecord = sourceRecords[i]; 426 + bySource[sourceName].scanned += 1; 427 + 428 + const sourceUri = String(sourceRecord.uri || "").trim(); 429 + if (!sourceUri) { 430 + totals.skipped += 1; 431 + bySource[sourceName].skipped += 1; 432 + continue; 433 + } 434 + 435 + if (existingBySourceUri.has(sourceUri)) { 436 + totals.skipped += 1; 437 + bySource[sourceName].skipped += 1; 438 + continue; 439 + } 440 + 441 + const mapped = source.toDocument(sourceRecord); 442 + if (!mapped || !mapped.site || !mapped.title || !mapped.publishedAt) { 443 + totals.skipped += 1; 444 + bySource[sourceName].skipped += 1; 445 + continue; 446 + } 447 + 448 + const payload = { 449 + $type: TARGET_COLLECTION, 450 + ...mapped, 451 + sourceAtUri: sourceUri, 452 + sourceCollection: source.collection, 453 + }; 454 + 455 + const ref = sourceRecord.value?.ref; 456 + if (typeof ref === "string" && ref.trim()) { 457 + payload.sourceRef = ref.trim(); 458 + } 459 + 460 + if (dryRun) { 461 + console.log( 462 + ` [dry] ${sourceName} → ${truncate(mapped.title, 64)} (${mapped.publishedAt.slice(0, 10)})`, 463 + ); 464 + totals.created += 1; 465 + bySource[sourceName].created += 1; 466 + existingBySourceUri.add(sourceUri); 467 + continue; 468 + } 469 + 470 + try { 471 + const result = await agent.com.atproto.repo.createRecord({ 472 + repo: user.did, 473 + collection: TARGET_COLLECTION, 474 + record: payload, 475 + }); 476 + 477 + const uri = result.uri || result.data?.uri || ""; 478 + const rkey = rkeyFromUri(uri); 479 + console.log(` ✅ ${sourceName} → ${rkey}`); 480 + 481 + totals.created += 1; 482 + bySource[sourceName].created += 1; 483 + existingBySourceUri.add(sourceUri); 484 + } catch (error) { 485 + console.log(` ❌ ${sourceName}: ${error.message}`); 486 + totals.failed += 1; 487 + bySource[sourceName].failed += 1; 488 + } 489 + 490 + if ((i + 1) % batchSize === 0 && i < sourceRecords.length - 1) { 491 + await sleep(delayMs); 492 + } 493 + } 494 + } 495 + } 496 + 497 + console.log("\n" + "═".repeat(60)); 498 + console.log("Standard.site Sync Summary\n"); 499 + console.log(`Users processed: ${totals.users}`); 500 + console.log(`Created: ${totals.created}`); 501 + console.log(`Skipped: ${totals.skipped}`); 502 + console.log(`Failed: ${totals.failed}\n`); 503 + 504 + for (const sourceName of sources) { 505 + const stats = bySource[sourceName]; 506 + console.log( 507 + `${sourceName.padEnd(8)} scanned:${String(stats.scanned).padStart(5)} created:${String(stats.created).padStart(5)} skipped:${String(stats.skipped).padStart(5)} failed:${String(stats.failed).padStart(5)}`, 508 + ); 509 + } 510 + 511 + if (dryRun) { 512 + console.log("\n💡 Dry run only. Re-run without --dry-run to create records."); 513 + } 514 + 515 + console.log(); 516 + await database.disconnect(); 517 + } 518 + 519 + main() 520 + .then(() => { 521 + process.exit(0); 522 + }) 523 + .catch((error) => { 524 + console.error(`\n❌ Fatal: ${error.message}`); 525 + process.exit(1); 526 + });
+2
package.json
··· 51 51 "at:invite": "node at/cli.mjs invite", 52 52 "at:accounts": "node at/cli.mjs accounts", 53 53 "at:sync": "node at/cli.mjs sync:status", 54 + "at:standard:sync": "node at/scripts/atproto/backfill-standard-site-documents.mjs", 55 + "at:standard:dry": "node at/scripts/atproto/backfill-standard-site-documents.mjs --dry-run", 54 56 "profile:secret:rotate": "node utilities/rotate-profile-stream-secret.mjs", 55 57 "ac": "./aesthetic", 56 58 "admin:udp": "ssh root@157.245.134.225",
+591
papers/arxiv-identity/identity.tex
··· 1 + % !TEX program = xelatex 2 + \documentclass[10pt,letterpaper,twocolumn]{article} 3 + 4 + % === GEOMETRY === 5 + \usepackage[top=0.75in, bottom=0.75in, left=0.75in, right=0.75in]{geometry} 6 + 7 + % === FONTS === 8 + \usepackage{fontspec} 9 + \usepackage{unicode-math} 10 + 11 + \setmainfont{Latin Modern Roman} 12 + \setsansfont{Latin Modern Sans} 13 + 14 + % Custom AC fonts 15 + \newfontfamily\acbold{ywft-processing-bold}[ 16 + Path=../../system/public/type/webfonts/, 17 + Extension=.ttf 18 + ] 19 + \newfontfamily\aclight{ywft-processing-light}[ 20 + Path=../../system/public/type/webfonts/, 21 + Extension=.ttf 22 + ] 23 + \setmonofont{Latin Modern Mono}[Scale=0.85] 24 + 25 + % === PACKAGES === 26 + \usepackage{xcolor} 27 + \usepackage{titlesec} 28 + \usepackage{enumitem} 29 + \usepackage{booktabs} 30 + \usepackage{tabularx} 31 + \usepackage{multicol} 32 + \usepackage{fancyhdr} 33 + \usepackage{hyperref} 34 + \usepackage{graphicx} 35 + \graphicspath{{figures/}{../../papers/arxiv-ac/figures/}} 36 + \usepackage{ragged2e} 37 + \usepackage{microtype} 38 + \usepackage{listings} 39 + \usepackage{natbib} 40 + \usepackage[colorspec=0.92]{draftwatermark} 41 + 42 + % === COLORS (AC palette) === 43 + \definecolor{acpink}{RGB}{180,72,135} 44 + \definecolor{acpurple}{RGB}{120,80,180} 45 + \definecolor{acdark}{RGB}{64,56,74} 46 + \definecolor{acgray}{RGB}{119,119,119} 47 + \definecolor{draftcolor}{RGB}{180,72,135} 48 + 49 + % === DRAFT WATERMARK === 50 + \DraftwatermarkOptions{ 51 + text=WORKING DRAFT, 52 + fontsize=3cm, 53 + color=draftcolor!18, 54 + angle=45, 55 + pos={0.5\paperwidth, 0.5\paperheight} 56 + } 57 + 58 + % === JS SYNTAX COLORS === 59 + \definecolor{jskw}{RGB}{119,51,170} 60 + \definecolor{jsfn}{RGB}{0,136,170} 61 + \definecolor{jsstr}{RGB}{170,120,0} 62 + \definecolor{jsnum}{RGB}{204,0,102} 63 + \definecolor{jscmt}{RGB}{102,102,102} 64 + 65 + % === HYPERREF === 66 + \hypersetup{ 67 + colorlinks=true, 68 + linkcolor=acpurple, 69 + urlcolor=acpurple, 70 + citecolor=acpurple, 71 + pdfauthor={@jeffrey}, 72 + pdftitle={Handle Identity on the AT Protocol: From Auth0 to Decentralized Sign-In}, 73 + } 74 + 75 + % === SECTION FORMATTING === 76 + \titleformat{\section} 77 + {\normalfont\bfseries\normalsize\uppercase} 78 + {\thesection.} 79 + {0.5em} 80 + {} 81 + \titlespacing{\section}{0pt}{1.2em}{0.3em} 82 + 83 + \titleformat{\subsection} 84 + {\normalfont\bfseries\small} 85 + {\thesubsection} 86 + {0.5em} 87 + {} 88 + \titlespacing{\subsection}{0pt}{0.8em}{0.2em} 89 + 90 + % === HEADER/FOOTER === 91 + \pagestyle{fancy} 92 + \fancyhf{} 93 + \renewcommand{\headrulewidth}{0pt} 94 + \fancyhead[C]{\footnotesize\color{acpink}\textit{Working Draft --- not for citation}} 95 + \fancyfoot[C]{\footnotesize\thepage} 96 + 97 + % === CUSTOM COMMANDS === 98 + \newcommand{\acdot}{{\color{acpink}.}} 99 + \newcommand{\ac}{\textsc{Aesthetic.Computer}} 100 + \newcommand{\atproto}{\textsc{AT Protocol}} 101 + 102 + % Random caps for Aesthetic.Computer branding 103 + \newcount\acrandtmp 104 + \newcommand{\acrandletter}[2]{% 105 + \acrandtmp=\uniformdeviate 2\relax 106 + \ifnum\acrandtmp=0\relax#1\else#2\fi% 107 + } 108 + \newcommand{\acrandname}{% 109 + \acrandletter{a}{A}\acrandletter{e}{E}\acrandletter{s}{S}\acrandletter{t}{T}% 110 + \acrandletter{h}{H}\acrandletter{e}{E}\acrandletter{t}{T}\acrandletter{i}{I}% 111 + \acrandletter{c}{C}{\color{acpink}.}\acrandletter{c}{C}\acrandletter{o}{O}% 112 + \acrandletter{m}{M}\acrandletter{p}{P}\acrandletter{u}{U}\acrandletter{t}{T}% 113 + \acrandletter{e}{E}\acrandletter{r}{R}% 114 + } 115 + 116 + % === LISTINGS === 117 + \lstdefinelanguage{acjs}{ 118 + morekeywords=[1]{function,export,const,let,var,return,if,else,new,async,await,import,from}, 119 + morekeywords=[2]{wipe,ink,line,box,circle,write,screen,params,colon,jump,send,store,net,sound,speaker,system}, 120 + sensitive=true, 121 + morecomment=[l]{//}, 122 + morestring=[b]", 123 + morestring=[b]', 124 + morestring=[b]`, 125 + escapeinside={|}{|}, 126 + } 127 + 128 + \lstdefinestyle{acjsstyle}{ 129 + language=acjs, 130 + keywordstyle=[1]\color{jskw}\bfseries, 131 + keywordstyle=[2]\color{jsfn}\bfseries, 132 + commentstyle=\color{jscmt}\itshape, 133 + stringstyle=\color{jsstr}, 134 + } 135 + 136 + \lstset{ 137 + basicstyle=\ttfamily\small, 138 + breaklines=true, 139 + frame=single, 140 + rulecolor=\color{acgray!30}, 141 + backgroundcolor=\color{acgray!5}, 142 + xleftmargin=0.5em, 143 + xrightmargin=0.5em, 144 + aboveskip=0.5em, 145 + belowskip=0.5em, 146 + } 147 + 148 + % === LIST SETTINGS === 149 + \setlist[itemize]{nosep, leftmargin=1.2em, itemsep=0.1em} 150 + \setlist[enumerate]{nosep, leftmargin=1.2em} 151 + 152 + % === COLUMN SEPARATION === 153 + \setlength{\columnsep}{1.8em} 154 + 155 + % === PARAGRAPH SETTINGS === 156 + \setlength{\parindent}{1em} 157 + \setlength{\parskip}{0.3em} 158 + 159 + % Hyphenation for narrow two-column layout 160 + \tolerance=800 161 + \emergencystretch=1em 162 + \hyphenpenalty=50 163 + 164 + \begin{document} 165 + 166 + % ============ TITLE BLOCK ============ 167 + 168 + \twocolumn[{% 169 + \begin{center} 170 + \includegraphics[height=4em]{pals}\par\vspace{0.5em} 171 + {\acbold\fontsize{24pt}{28pt}\selectfont\color{acdark} Handle Identity on the AT Protocol}\par 172 + \vspace{0.2em} 173 + {\aclight\fontsize{11pt}{13pt}\selectfont\color{acpink} From Auth0 to Decentralized Sign-In on Aesthetic Computer}\par 174 + \vspace{0.3em} 175 + {\aclight\fontsize{9pt}{11pt}\selectfont\color{acgray} ATProto OAuth, Handle Verification, and Portable Creative Identity}\par 176 + \vspace{0.6em} 177 + {\normalsize @jeffrey}\par 178 + {\small\color{acgray} Aesthetic.Computer}\par 179 + {\small\color{acgray} ORCID: \href{https://orcid.org/0009-0007-4460-4913}{0009-0007-4460-4913}}\par 180 + \vspace{0.3em} 181 + {\small\color{acpurple} \url{https://aesthetic.computer}}\par 182 + \vspace{0.6em} 183 + \rule{\textwidth}{1.5pt} 184 + \vspace{0.5em} 185 + \end{center} 186 + 187 + \begin{center} 188 + {\small\color{acpink}\textbf{[ working draft --- not for citation ]}} 189 + \end{center} 190 + \vspace{0.3em} 191 + 192 + \begin{quote} 193 + \small\noindent\textbf{Abstract.} 194 + Aesthetic Computer currently authenticates users through Auth0 and maintains a parallel identity on a self-hosted AT Protocol Personal Data Server (PDS) at \texttt{at.aesthetic.computer}. Each verified user receives a DID and a PDS handle, but authentication flows through a centralized OAuth provider. We propose collapsing this dual-identity architecture by adopting AT Protocol OAuth~\citep{atproto2024oauth} as a primary sign-in method, allowing anyone with a Bluesky or ATProto identity to authenticate directly and claim the equivalent handle on Aesthetic Computer. This paper surveys the \atproto{} identity stack---DIDs, handle verification, OAuth 2.1 with DPoP and PAR---examines how pckt.blog and other ATProto-native applications implement decentralized sign-in~\citep{pcktblog2025}, maps the current AC authentication architecture, and proposes a phased migration from Auth0 dependency to \atproto{}-first identity. The central argument: on a platform that already runs its own PDS and mints its own DIDs, the centralized identity provider is the vestigial organ. Removing it simplifies the stack, eliminates a paid dependency, and makes AC a first-class citizen of the federated social web. 195 + \end{quote} 196 + \vspace{0.5em} 197 + }] 198 + 199 + % ============ 1. INTRODUCTION ============ 200 + 201 + \section{Introduction} 202 + 203 + Identity on the web is a landlord problem. You do not own your handle on Twitter, your username on Instagram, or your login on any platform that can delete your account. The AT Protocol~\citep{atproto2024spec}---the decentralized social networking protocol behind Bluesky---proposes a different arrangement: your identity is a cryptographic key pair, your handle is a domain name you control, and your data lives on a Personal Data Server that you can move between providers. 204 + 205 + Aesthetic Computer has operated a hybrid identity system since 2024. Auth0~\citep{auth0spa} handles authentication: OAuth 2.0 with PKCE, refresh tokens, and a managed user database. Separately, a self-hosted PDS at \texttt{at.aesthetic.computer} mints ATProto identities for every verified user. The result is a doubled system: two identities per user, two credential stores, two handle sync paths, and a paid Auth0 subscription bridging the gap. 206 + 207 + This paper asks: what if the PDS \emph{is} the identity provider? 208 + 209 + The answer is not hypothetical. pckt.blog~\citep{pcktblog2025}, a blogging platform built on the \atproto{}, authenticates users entirely through \atproto{} OAuth. Users sign in with their Bluesky handle, their self-hosted PDS, or any compatible identity provider. No Auth0, no Firebase, no centralized user database. The content syncs to the user's own PDS using shared lexicons from Standard.site~\citep{standardsite2025}. 210 + 211 + We examine how this model applies to Aesthetic Computer---a creative computing platform with 600+ interactive pieces, multiplayer sessions, a KidLisp programming language, and a native bare-metal OS~\citep{scudder2026os}---and propose a phased migration that preserves backward compatibility while moving toward decentralized identity. 212 + 213 + % ============ 2. THE CURRENT AC IDENTITY ARCHITECTURE ============ 214 + 215 + \section{Current Architecture} 216 + \label{sec:current} 217 + 218 + \subsection{Auth0 as Identity Provider} 219 + 220 + Authentication flows through Auth0's SPA SDK. On page load, \texttt{boot.mjs} checks localStorage for cached Auth0 state. If a session exists (or an OAuth callback is detected), it initializes the Auth0 client with PKCE and refresh tokens, exchanges authorization codes for access tokens, and retrieves the user profile. The Auth0 \texttt{sub} field (e.g., \texttt{auth0|abc123} or \texttt{google-oauth2|xyz}) serves as the internal user identifier. 221 + 222 + \begin{lstlisting}[style=acjsstyle] 223 + // boot.mjs: current Auth0 flow 224 + const auth0 = await createAuth0Client({ 225 + domain: |\textcolor{jsstr}{"hi.aesthetic.computer"}|, 226 + clientId: |\textcolor{jsstr}{"LVdZaM..."}|, 227 + cacheLocation: |\textcolor{jsstr}{"localstorage"}|, 228 + useRefreshTokens: true, 229 + }); 230 + const user = await auth0.getUser(); 231 + // { sub, email, email_verified, ... } 232 + \end{lstlisting} 233 + 234 + \subsection{Handle System} 235 + 236 + Handles are stored in a MongoDB \texttt{@handles} collection, mapping Auth0 \texttt{sub} to a 1--16 character alphanumeric string (with \texttt{.} and \texttt{\_} allowed). Validation enforces no leading/trailing punctuation, case-insensitive uniqueness, and profanity filtering. Handles are the primary user-facing identity: URL-addressable (\texttt{@handle/piece-name}), visible in chat, and spoken aloud by AC Native OS. 237 + 238 + \subsection{ATProto Shadow Identity} 239 + 240 + On first email verification, an Auth0 webhook triggers \texttt{createAtprotoAccount()}, which: 241 + 242 + \begin{enumerate} 243 + \item Generates a 32-character password 244 + \item Creates an invite code on the PDS 245 + \item Creates an account at \texttt{handle.at.aesthetic.computer} 246 + \item Stores the DID, handle, and encrypted password in MongoDB (\texttt{users.atproto}) 247 + \end{enumerate} 248 + 249 + When a user changes their AC handle, \texttt{updateAtprotoHandle()} syncs the change to the PDS. Content (paintings, moods, KidLisp snippets, tapes, news) is mirrored to the PDS via six custom lexicons (\texttt{computer.aesthetic.*}). The ATProto identity is real and functional---but the user never authenticates through it. It is a shadow identity: present, synced, but not sovereign. 250 + 251 + \subsection{The Duplication Problem} 252 + 253 + This architecture means: 254 + 255 + \begin{itemize} 256 + \item Two credential stores (Auth0 + PDS) 257 + \item Two handle namespaces (AC handles + PDS handles) 258 + \item Two sync paths (handle changes propagate Auth0 $\to$ MongoDB $\to$ PDS) 259 + \item A paid dependency (Auth0 subscription) 260 + \item No interoperability (a Bluesky user cannot sign into AC with their existing identity) 261 + \end{itemize} 262 + 263 + The PDS already knows who each user is. It already stores their DID, their handle, their content. The Auth0 layer adds cost and complexity without adding capability that the PDS cannot provide. 264 + 265 + % ============ 3. THE AT PROTOCOL IDENTITY STACK ============ 266 + 267 + \section{The AT Protocol Identity Stack} 268 + \label{sec:atproto} 269 + 270 + Understanding the migration requires understanding the three layers of \atproto{} identity: DIDs, handles, and OAuth. 271 + 272 + \subsection{Decentralized Identifiers (DIDs)} 273 + 274 + A DID~\citep{w3cdid2022, atproto2024did} is a persistent, cryptographically verifiable identifier. The \atproto{} primarily uses \texttt{did:plc}, a method designed for strong consistency, high availability, and key rotation without losing identity. Each DID resolves to a document containing: 275 + 276 + \begin{itemize} 277 + \item A \textbf{signing key} (P-256 or K-256)~\citep{atproto2024crypto} for authenticating repository updates 278 + \item \textbf{Rotation keys} for account recovery 279 + \item A \textbf{PDS endpoint} URL 280 + \item The user's current \textbf{handle} 281 + \end{itemize} 282 + 283 + The DID is the stable identity. Handles change; keys rotate; PDS providers come and go. The DID persists. AC already mints DIDs for every user through its PDS. The infrastructure exists. 284 + 285 + \subsection{Handle Verification} 286 + 287 + An \atproto{} handle~\citep{atproto2024handle} is a domain name that bidirectionally resolves to a DID. Verification uses two methods: 288 + 289 + \textbf{DNS TXT}: Place a record at \texttt{\_atproto.example.com}: 290 + \begin{lstlisting} 291 + _atproto.example.com TXT "did=did:plc:abc..." 292 + \end{lstlisting} 293 + 294 + \textbf{HTTPS Well-Known}: Serve the DID at: 295 + \begin{lstlisting} 296 + GET /.well-known/atproto-did 297 + Response: did:plc:abc... 298 + \end{lstlisting} 299 + 300 + Both methods require the handle to resolve to the DID \emph{and} the DID document to claim the handle back. This bidirectional verification means: if you control the domain, you control the handle. No central authority assigns handles; DNS is the authority. 301 + 302 + For AC, this means \texttt{jeffrey.at.aesthetic.computer} is a real, verifiable ATProto handle because AC controls both the DNS and the PDS. But it also means a user who already owns \texttt{alice.bsky.social} or \texttt{alice.dev} has a cryptographically verified identity that AC can trust without a password. 303 + 304 + \subsection{ATProto OAuth} 305 + 306 + The \atproto{} OAuth specification~\citep{atproto2024oauth} extends OAuth 2.1 with three mandatory security mechanisms: 307 + 308 + \textbf{PKCE} (Proof Key for Code Exchange)~\citep{rfc7636pkce}: Prevents authorization code interception. The client generates a random verifier, sends its hash with the authorization request, and proves possession of the original verifier during token exchange. 309 + 310 + \textbf{PAR} (Pushed Authorization Requests)~\citep{rfc9126par}: The client submits authorization parameters via POST to the authorization server \emph{before} redirecting the user. This prevents parameter tampering in the redirect URL. 311 + 312 + \textbf{DPoP} (Demonstrating Proof of Possession)~\citep{rfc9449dpop}: Each request includes a signed JWT proving the client holds the private key associated with the token. Even if an access token leaks, it cannot be used by a different client. 313 + 314 + \subsubsection{Client Identification} 315 + 316 + Unlike traditional OAuth, \atproto{} does not require pre-registration with each authorization server. Instead, clients publish metadata at a public HTTPS URL: 317 + 318 + \begin{lstlisting}[style=acjsstyle] 319 + // aesthetic.computer/oauth/client-metadata.json 320 + { 321 + |\textcolor{jsstr}{"client\_id"}|: |\textcolor{jsstr}{"https://aesthetic.computer/..."}|, 322 + |\textcolor{jsstr}{"client\_name"}|: |\textcolor{jsstr}{"Aesthetic Computer"}|, 323 + |\textcolor{jsstr}{"redirect\_uris"}|: [|\textcolor{jsstr}{"https://..."}|], 324 + |\textcolor{jsstr}{"grant\_types"}|: [|\textcolor{jsstr}{"authorization\_code"}|], 325 + |\textcolor{jsstr}{"dpop\_bound\_access\_tokens"}|: true 326 + } 327 + \end{lstlisting} 328 + 329 + Any PDS can discover and verify the client by fetching this URL. No pre-shared secrets, no app store registration, no API key management. 330 + 331 + \subsubsection{The Flow} 332 + 333 + \begin{enumerate} 334 + \item User enters their handle (e.g., \texttt{alice.bsky.social}) 335 + \item Client resolves handle $\to$ DID $\to$ PDS endpoint $\to$ authorization server 336 + \item Client pushes authorization parameters via PAR 337 + \item User is redirected to their PDS's authorization UI 338 + \item User approves; PDS redirects back with authorization code 339 + \item Client exchanges code for DPoP-bound access token 340 + \item Client verifies the returned DID matches the expected identity 341 + \end{enumerate} 342 + 343 + The critical difference from Auth0: the user authenticates with \emph{their own server}. AC never sees a password. The PDS---whether Bluesky's, AC's own, or a self-hosted instance---is the identity authority. 344 + 345 + % ============ 4. HOW PCKT.BLOG DOES IT ============ 346 + 347 + \section{Case Study: pckt.blog} 348 + \label{sec:pckt} 349 + 350 + pckt.blog~\citep{pcktblog2025} is a blogging platform that authenticates exclusively through \atproto{} OAuth. Its implementation demonstrates the practical viability of ATProto-only authentication for a content platform. 351 + 352 + \subsection{Authentication} 353 + 354 + Users sign in by entering their ATProto handle. pckt.blog resolves the handle, discovers the authorization server, and initiates the OAuth flow. The user approves on their PDS (Bluesky, Blacksky, a self-hosted server). pckt.blog receives a DPoP-bound access token and the user's DID. No passwords are stored. No separate user database is maintained. 355 + 356 + \subsection{Data Sovereignty} 357 + 358 + Content syncs to the user's own PDS using Standard.site lexicons~\citep{standardsite2025}: 359 + 360 + \begin{itemize} 361 + \item \texttt{site.standard.publication} --- blog collections 362 + \item \texttt{site.standard.document} --- individual articles 363 + \item \texttt{site.standard.graph.subscription} --- follow relationships 364 + \end{itemize} 365 + 366 + If pckt.blog disappears, the user's content remains on their PDS, accessible to any compatible reader. This is the promise of ATProto: the platform is a view, not a silo. 367 + 368 + \subsection{Implications for AC} 369 + 370 + pckt.blog proves that a content-oriented platform can operate entirely on ATProto identity. The parallels to AC are direct: 371 + 372 + \begin{itemize} 373 + \item pckt.blog publishes articles; AC publishes pieces, paintings, and moods 374 + \item pckt.blog uses Standard.site lexicons; AC has six custom \texttt{computer.aesthetic.*} lexicons 375 + \item pckt.blog's users own their content on their PDS; AC already mirrors content to its PDS 376 + \item Both are Node.js/JavaScript applications 377 + \end{itemize} 378 + 379 + The gap: pckt.blog was built ATProto-native. AC has an existing Auth0 user base that must be migrated gracefully. 380 + 381 + % ============ 5. PROPOSED MIGRATION ============ 382 + 383 + \section{Proposed Migration} 384 + \label{sec:migration} 385 + 386 + \subsection{Phase 1: ATProto OAuth as Secondary Sign-In} 387 + 388 + Add ``Sign in with Bluesky'' alongside Auth0, without removing Auth0. 389 + 390 + \textbf{Infrastructure:} 391 + \begin{enumerate} 392 + \item Publish client metadata at \texttt{aesthetic.computer/oauth/client-metadata.json} 393 + \item Add \texttt{@atproto/oauth-client-node}~\citep{npmAtprotoOauth} to the backend 394 + \item Create two new Netlify functions: 395 + \begin{itemize} 396 + \item \texttt{POST /api/atproto-auth/start} --- resolve handle, PAR, redirect 397 + \item \texttt{GET /api/atproto-auth/callback} --- code exchange with DPoP 398 + \end{itemize} 399 + \item Store sessions in Redis (DID, handle, DPoP key pair, tokens) 400 + \end{enumerate} 401 + 402 + \textbf{Client flow:} A new ``Sign in with Bluesky'' button in \texttt{boot.mjs} triggers the ATProto OAuth flow. On success, \texttt{window.acUSER} is populated from the ATProto session (DID as \texttt{sub}, handle, no email unless the user provides one). The piece API surface is identical---pieces see a user with a handle, regardless of which auth path created the session. 403 + 404 + \subsection{Phase 2: Handle Bridging} 405 + 406 + When a user signs in via ATProto, bridge their handle to AC: 407 + 408 + \begin{enumerate} 409 + \item Extract the username from the ATProto handle (e.g., \texttt{alice} from \texttt{alice.bsky.social}) 410 + \item Check availability against the AC \texttt{@handles} collection 411 + \item If available and valid (1--16 chars, alphanumeric), offer one-click claim 412 + \item If taken, check if the existing owner's email matches---offer account linking 413 + \item If taken by someone else, prompt for an alternative 414 + \item Store a DID $\leftrightarrow$ AC sub mapping in MongoDB for future sign-ins 415 + \end{enumerate} 416 + 417 + Custom domain handles (e.g., \texttt{alice.dev}) require the user to choose an AC handle manually, since the domain itself may not map to a valid handle string. 418 + 419 + \subsection{Phase 3: Identity Linking} 420 + 421 + Existing Auth0 users can link their ATProto identity: 422 + 423 + \begin{enumerate} 424 + \item From account settings, initiate ATProto OAuth 425 + \item On success, store the external DID alongside the Auth0 sub 426 + \item Future sign-ins accept either auth path 427 + \item Content can optionally sync to the user's external PDS (not just AC's PDS) 428 + \end{enumerate} 429 + 430 + This phase turns the existing \texttt{users.atproto} field from a shadow identity into a first-class identity link. 431 + 432 + \subsection{Phase 4: ATProto-Primary} 433 + 434 + Once the migration is validated: 435 + 436 + \begin{enumerate} 437 + \item New signups default to ATProto OAuth (creating accounts on AC's PDS) 438 + \item Auth0 remains as a legacy path for existing users 439 + \item Gradually sunset Auth0 as users link their ATProto identities 440 + \item Remove Auth0 dependency, eliminate subscription cost 441 + \end{enumerate} 442 + 443 + \subsection{PDS Routing} 444 + 445 + A key architectural decision: where does content go? 446 + 447 + \begin{itemize} 448 + \item \textbf{Auth0-only users}: content syncs to AC's PDS (current behavior) 449 + \item \textbf{ATProto users with external PDS}: content syncs to \emph{their} PDS 450 + \item \textbf{ATProto users on AC's PDS}: content stays on AC's PDS 451 + \end{itemize} 452 + 453 + This means the backend sync functions (\texttt{media-atproto.mjs}, \texttt{painting-atproto.mjs}, etc.) need a conditional path: resolve the user's PDS endpoint from their DID document, and write to that endpoint rather than assuming AC's PDS. 454 + 455 + % ============ 6. HANDLE SEMANTICS ============ 456 + 457 + \section{Handle Semantics} 458 + \label{sec:handles} 459 + 460 + The handle is the most human-visible piece of the identity stack, and the migration raises questions about what a handle \emph{means}. 461 + 462 + \subsection{Current Handle Model} 463 + 464 + Today, an AC handle is: 465 + \begin{itemize} 466 + \item A 1--16 character string, first-come-first-served 467 + \item Unique within the AC namespace 468 + \item Used for URLs (\texttt{@alice/painting}), chat, and OS personalization 469 + \item Stored in MongoDB, cached in Redis 470 + \item Mirrored to the PDS as \texttt{alice.at.aesthetic.computer} 471 + \end{itemize} 472 + 473 + \subsection{ATProto Handle Model} 474 + 475 + An ATProto handle is: 476 + \begin{itemize} 477 + \item A domain name (any valid DNS name) 478 + \item Verified bidirectionally against a DID 479 + \item Globally unique by DNS authority 480 + \item Portable across services 481 + \end{itemize} 482 + 483 + \subsection{Bridging the Models} 484 + 485 + The proposal: an AC handle is an \emph{alias} that maps to a DID. The source of truth shifts from MongoDB to the DID layer. Multiple sign-in methods (Auth0, ATProto OAuth) resolve to the same DID, which resolves to the same AC handle. 486 + 487 + For AC Native OS~\citep{scudder2026os}, where the handle is inscribed in \texttt{config.json} on the boot partition, this means the device identity is backed by a cryptographic identity. ``Hi @alice'' on the boot screen means Alice's DID, Alice's signing key, Alice's portable identity---not just a string in a config file. 488 + 489 + % ============ 7. OPEN QUESTIONS ============ 490 + 491 + \section{Open Questions} 492 + \label{sec:questions} 493 + 494 + \textbf{Handle priority.} If \texttt{alice} is unclaimed on AC but \texttt{alice.bsky.social} signs in, should she get it automatically? First-come-first-served is simple but allows squatting. ATProto-verified priority is fairer but adds complexity. 495 + 496 + \textbf{Email requirement.} Auth0 provides verified email for account recovery. ATProto OAuth does not guarantee email. Should AC require an email for full account features (purchasing, notifications)? 497 + 498 + \textbf{Multi-tenant.} AC operates a second tenant (\texttt{sotce}) with separate Auth0. How do ATProto identities map across tenants? The DID is tenant-agnostic, which may simplify cross-tenant identity. 499 + 500 + \textbf{Session server.} Real-time features (chat, multiplayer) authenticate via Auth0 tokens forwarded through WebSocket. The session server must accept ATProto-issued tokens or a unified session token. 501 + 502 + \textbf{Device pairing.} AC Native OS pairs via a 6-character code exchanged through Auth0. The ATProto equivalent: scan a QR code that initiates an OAuth flow on the phone, delivering tokens to the device. 503 + 504 + \textbf{Admin identity.} Admin is currently \texttt{handle === "jeffrey" \&\& sub === ADMIN\_SUB}. Under ATProto-first auth, admin is \texttt{did === admin\_did}---cleaner, cryptographically grounded. 505 + 506 + % ============ 8. RELATED WORK ============ 507 + 508 + \section{Related Work} 509 + \label{sec:related} 510 + 511 + \textbf{Decentralized identity.} The W3C DID specification~\citep{w3cdid2022} provides the formal framework for decentralized identifiers. The AT Protocol's \texttt{did:plc} method~\citep{atproto2024did} extends this with strong consistency guarantees and a centralized-but-auditable directory at \texttt{plc.directory}. This is a pragmatic compromise: full decentralization of identity resolution remains an open problem, and \texttt{did:plc} trades some decentralization for operational reliability. 512 + 513 + \textbf{ATProto-native applications.} pckt.blog~\citep{pcktblog2025} demonstrates blogging; Leaflet.pub and Offprint.app collaborate on shared lexicons via Standard.site~\citep{standardsite2025}. These applications prove the viability of ATProto-only auth for content platforms. 514 + 515 + \textbf{OAuth security.} DPoP~\citep{rfc9449dpop} prevents token theft; PAR~\citep{rfc9126par} prevents parameter tampering; PKCE~\citep{rfc7636pkce} prevents code interception. Together they represent the state of the art in browser-based OAuth security---significantly stronger than the Auth0 SPA flow AC currently uses. 516 + 517 + \textbf{Convivial identity.} Illich's tools for conviviality~\citep{illich1973tools} frame the question: does the identity system expand personal autonomy, or does it require dependence on a provider? Auth0 is a managed service---convenient but dependent. ATProto identity is portable, self-verifiable, and provider-independent. Nelson's vision of personal computing~\citep{nelson1974computerlib} extends naturally to personal identity: you should own your name on the network. 518 + 519 + % ============ 9. CONCLUSION ============ 520 + 521 + \section{Conclusion} 522 + 523 + Aesthetic Computer already runs a PDS, mints DIDs, syncs content to ATProto records, and publishes custom lexicons. The only piece missing is letting users authenticate through that infrastructure instead of routing through Auth0. The migration is not a rewrite---it is the removal of a workaround. 524 + 525 + The phased approach (ATProto as secondary sign-in $\to$ handle bridging $\to$ identity linking $\to$ ATProto-primary) ensures no existing user is disrupted. The end state: a creative computing platform where your handle is a cryptographic identity, your content lives on a server you control, and signing in means proving you own your keys---not trusting a third party to vouch for you. 526 + 527 + The PDS is already running. The DIDs are already minted. The lexicons are already published. It is time to let users sign in through the front door. 528 + 529 + % ============ REFERENCES ============ 530 + 531 + \vspace{0.5em} 532 + \noindent\rule{\columnwidth}{0.5pt} 533 + 534 + \subsection*{Reference Links} 535 + 536 + \noindent\small 537 + 538 + \textbf{AT Protocol Specifications:} 539 + \begin{itemize} 540 + \item \url{https://atproto.com/specs} --- Full protocol specification 541 + \item \url{https://atproto.com/specs/oauth} --- OAuth specification 542 + \item \url{https://atproto.com/specs/handle} --- Handle resolution 543 + \item \url{https://atproto.com/specs/cryptography} --- Cryptographic methods 544 + \item \url{https://web.plc.directory/spec/v0.1/did-plc} --- DID PLC specification 545 + \item \url{https://atproto.com/guides/lexicon} --- Lexicon schema system 546 + \item \url{https://atproto.com/guides/oauth-patterns} --- OAuth implementation patterns 547 + \end{itemize} 548 + 549 + \textbf{Implementation Guides:} 550 + \begin{itemize} 551 + \item \url{https://docs.bsky.app/blog/oauth-atproto} --- Building ATProto OAuth apps 552 + \item \url{https://docs.bsky.app/docs/advanced-guides/resolving-identities} --- Identity resolution 553 + \item \url{https://docs.bsky.app/docs/advanced-guides/oauth-client} --- OAuth client guide 554 + \end{itemize} 555 + 556 + \textbf{NPM Packages:} 557 + \begin{itemize} 558 + \item \texttt{@atproto/oauth-client-node} --- Node.js OAuth client 559 + \item \texttt{@atproto/oauth-client-browser} --- Browser OAuth client 560 + \item \texttt{@atproto/api} --- TypeScript XRPC client 561 + \item \texttt{@atproto/identity} --- DID/handle resolution 562 + \item \texttt{@atcute/oauth-browser-client} --- Lightweight alternative 563 + \end{itemize} 564 + 565 + \textbf{Reference Implementations:} 566 + \begin{itemize} 567 + \item \url{https://github.com/pilcrowonpaper/atproto-oauth-example} --- Astro OAuth example 568 + \item \url{https://github.com/bluesky-social/atproto} --- Official ATProto monorepo 569 + \item \url{https://standard.site/docs/introduction/} --- Standard.site shared lexicons 570 + \end{itemize} 571 + 572 + \textbf{Applications Using ATProto Auth:} 573 + \begin{itemize} 574 + \item pckt.blog --- Blogging on the open social web 575 + \item Leaflet.pub --- Long-form publishing 576 + \item Offprint.app --- Collaborative writing 577 + \end{itemize} 578 + 579 + \textbf{IETF Standards:} 580 + \begin{itemize} 581 + \item RFC 9449 --- DPoP (Demonstrating Proof of Possession) 582 + \item RFC 7636 --- PKCE (Proof Key for Code Exchange) 583 + \item RFC 9126 --- PAR (Pushed Authorization Requests) 584 + \end{itemize} 585 + 586 + \vspace{0.5em} 587 + 588 + \bibliographystyle{plainnat} 589 + \bibliography{references} 590 + 591 + \end{document}
+189
papers/arxiv-identity/references.bib
··· 1 + @misc{scudder2026ac, 2 + title={Aesthetic Computer '26: A Mobile-First Runtime for Creative Computing}, 3 + author={{@jeffrey}}, 4 + year={2026}, 5 + note={Companion paper describing the AC platform} 6 + } 7 + 8 + @misc{scudder2026os, 9 + title={AC Native OS '26: A Bare-Metal Creative Computing Operating System}, 10 + author={{@jeffrey}}, 11 + year={2026}, 12 + note={Companion paper describing the AC Native OS} 13 + } 14 + 15 + @misc{atproto2024spec, 16 + title={AT Protocol Specification}, 17 + author={{Bluesky PBC}}, 18 + year={2024}, 19 + howpublished={\url{https://atproto.com/specs}}, 20 + note={Decentralized social networking protocol} 21 + } 22 + 23 + @misc{atproto2024oauth, 24 + title={AT Protocol OAuth Specification}, 25 + author={{Bluesky PBC}}, 26 + year={2024}, 27 + howpublished={\url{https://atproto.com/specs/oauth}}, 28 + note={OAuth 2.1 with PKCE, DPoP, and PAR for decentralized authentication} 29 + } 30 + 31 + @misc{atproto2024handle, 32 + title={AT Protocol Handle Specification}, 33 + author={{Bluesky PBC}}, 34 + year={2024}, 35 + howpublished={\url{https://atproto.com/specs/handle}}, 36 + note={Handle resolution via DNS TXT and HTTPS well-known} 37 + } 38 + 39 + @misc{atproto2024did, 40 + title={DID PLC Specification v0.1}, 41 + author={{Bluesky PBC}}, 42 + year={2024}, 43 + howpublished={\url{https://web.plc.directory/spec/v0.1/did-plc}}, 44 + note={Decentralized Identifiers for AT Protocol} 45 + } 46 + 47 + @misc{atproto2024crypto, 48 + title={AT Protocol Cryptography Specification}, 49 + author={{Bluesky PBC}}, 50 + year={2024}, 51 + howpublished={\url{https://atproto.com/specs/cryptography}}, 52 + note={P-256 and K-256 elliptic curve signing} 53 + } 54 + 55 + @misc{bluesky2024oauth, 56 + title={OAuth for AT Protocol: Building Atproto Apps}, 57 + author={{Bluesky PBC}}, 58 + year={2024}, 59 + howpublished={\url{https://docs.bsky.app/blog/oauth-atproto}}, 60 + note={Implementation guide for atproto OAuth clients} 61 + } 62 + 63 + @misc{bluesky2024resolving, 64 + title={Resolving Bluesky Identities}, 65 + author={{Bluesky PBC}}, 66 + year={2024}, 67 + howpublished={\url{https://docs.bsky.app/docs/advanced-guides/resolving-identities}}, 68 + note={Guide to DID resolution and handle verification} 69 + } 70 + 71 + @misc{standardsite2025, 72 + title={Standard Site: Open Blog Publishing on AT Protocol}, 73 + author={{Standard.site}}, 74 + year={2025}, 75 + howpublished={\url{https://standard.site/docs/introduction/}}, 76 + note={Shared lexicons for blog content portability across ATProto} 77 + } 78 + 79 + @misc{pcktblog2025, 80 + title={Blogging on the Open Social Web and More Exciting Features}, 81 + author={{pckt.blog}}, 82 + year={2025}, 83 + howpublished={\url{https://devlog.pckt.blog/blogging-on-the-open-social-web-and-more-exciting-features-g9x55y6}}, 84 + note={Development journal on ATProto blog integration} 85 + } 86 + 87 + @misc{rfc9449dpop, 88 + title={{RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)}}, 89 + author={Fett, Daniel and Campbell, Brian and Bradley, John and Lodderstedt, Torsten and Jones, Michael and Waite, David}, 90 + year={2023}, 91 + howpublished={\url{https://datatracker.ietf.org/doc/html/rfc9449}}, 92 + note={IETF standard for binding tokens to client key pairs} 93 + } 94 + 95 + @misc{rfc7636pkce, 96 + title={{RFC 7636: Proof Key for Code Exchange by OAuth Public Clients}}, 97 + author={Sakimura, Nat and Bradley, John and Agarwal, Naveen}, 98 + year={2015}, 99 + howpublished={\url{https://datatracker.ietf.org/doc/html/rfc7636}}, 100 + note={PKCE for OAuth authorization code flow} 101 + } 102 + 103 + @misc{rfc9126par, 104 + title={{RFC 9126: OAuth 2.0 Pushed Authorization Requests}}, 105 + author={Lodderstedt, Torsten and Campbell, Brian and Sakimura, Nat and Tonge, Dave and Fett, Daniel}, 106 + year={2021}, 107 + howpublished={\url{https://datatracker.ietf.org/doc/html/rfc9126}}, 108 + note={Pushed Authorization Requests for OAuth} 109 + } 110 + 111 + @misc{pilcrow2024example, 112 + title={AT Protocol OAuth Example (Astro/Runtime-Agnostic)}, 113 + author={{pilcrowonpaper}}, 114 + year={2024}, 115 + howpublished={\url{https://github.com/pilcrowonpaper/atproto-oauth-example}}, 116 + note={Reference implementation for atproto OAuth client} 117 + } 118 + 119 + @misc{atprotopatterns2024, 120 + title={AT Protocol OAuth Patterns}, 121 + author={{Bluesky PBC}}, 122 + year={2024}, 123 + howpublished={\url{https://atproto.com/guides/oauth-patterns}}, 124 + note={Public client, BFF, and TMB patterns for atproto OAuth} 125 + } 126 + 127 + @book{nelson1974computerlib, 128 + title={Computer Lib / Dream Machines}, 129 + author={Nelson, Ted}, 130 + year={1974}, 131 + publisher={Self-published}, 132 + note={Revised edition 1987, Tempus Books/Microsoft Press} 133 + } 134 + 135 + @book{illich1973tools, 136 + title={Tools for Conviviality}, 137 + author={Illich, Ivan}, 138 + year={1973}, 139 + publisher={Harper \& Row}, 140 + address={New York} 141 + } 142 + 143 + @misc{w3cdid2022, 144 + title={{Decentralized Identifiers (DIDs) v1.0}}, 145 + author={{W3C}}, 146 + year={2022}, 147 + howpublished={\url{https://www.w3.org/TR/did-core/}}, 148 + note={W3C Recommendation for decentralized identifiers} 149 + } 150 + 151 + @misc{auth0spa, 152 + title={{Auth0 Single Page App SDK}}, 153 + author={{Auth0 by Okta}}, 154 + year={2024}, 155 + howpublished={\url{https://auth0.com/docs/libraries/auth0-single-page-app-sdk}}, 156 + note={JavaScript SDK for browser-based OAuth flows} 157 + } 158 + 159 + @misc{npmAtprotoOauth, 160 + title={@atproto/oauth-client-node}, 161 + author={{Bluesky PBC}}, 162 + year={2024}, 163 + howpublished={\url{https://www.npmjs.com/package/@atproto/oauth-client-node}}, 164 + note={Node.js AT Protocol OAuth client library} 165 + } 166 + 167 + @misc{npmAtprotoApi, 168 + title={@atproto/api}, 169 + author={{Bluesky PBC}}, 170 + year={2024}, 171 + howpublished={\url{https://www.npmjs.com/package/@atproto/api}}, 172 + note={TypeScript client for AT Protocol XRPC APIs} 173 + } 174 + 175 + @misc{atcuteOauth, 176 + title={@atcute/oauth-browser-client}, 177 + author={{mary-ext}}, 178 + year={2024}, 179 + howpublished={\url{https://github.com/mary-ext/atcute}}, 180 + note={Lightweight alternative atproto OAuth browser client} 181 + } 182 + 183 + @misc{atprotoLexicon, 184 + title={AT Protocol Lexicon Guide}, 185 + author={{Bluesky PBC}}, 186 + year={2024}, 187 + howpublished={\url{https://atproto.com/guides/lexicon}}, 188 + note={Schema definition system for ATProto records} 189 + }
+5
papers/cli.mjs
··· 170 170 siteName: "five-years-from-now-26-arxiv", 171 171 title: "Five Years from Now", 172 172 }, 173 + "arxiv-identity": { 174 + base: "identity", 175 + siteName: "handle-identity-atproto-26-arxiv", 176 + title: "Handle Identity on the AT Protocol", 177 + }, 173 178 }; 174 179 175 180 function texName(base, lang) {
+6
system/public/papers.aesthetic.computer/index.html
··· 485 485 <div class="meta-row"><span class="created" title="Created">03/21</span><span class="revisions" title="Revisions">r1</span><span class="updated" title="Last updated">Mar 21 06:24</span></div> 486 486 </div> 487 487 488 + <div class="p" data-paper-id="identity" data-no-cards="1"> 489 + <div class="title"><a href="/handle-identity-atproto-26-arxiv.pdf" data-base="/handle-identity-atproto-26-arxiv">Handle Identity on the AT Protocol</a></div> 490 + <div class="detail">From Auth0 to Decentralized Sign-In &middot; ATProto OAuth, DIDs, Handle Verification</div> 491 + <div class="meta-row"><span class="created" title="Created">03/23</span><span class="revisions" title="Revisions">r1</span><span class="updated" title="Last updated">Mar 23</span></div> 492 + </div> 493 + 488 494 <div class="p" data-paper-id="calarts" data-no-cards="1" data-psycho="1"> 489 495 <div class="title"><a href="/calarts-callouts-papers-26-arxiv.pdf" data-base="/calarts-callouts-papers-26-arxiv">CalArts, Callouts, and Papers</a></div> 490 496 <div class="detail"></div>
+16 -8
system/public/papers.aesthetic.computer/platter.html
··· 276 276 <h1>Research Platter</h1> 277 277 <div class="subtitle">Full knowledge base for Aesthetic Computer papers and research.</div> 278 278 <div class="stats"> 279 - <span>359</span> pieces · <span>76</span> lib modules · <span>92</span> functions · <span>156</span> plans · <span>95</span> reports · <span>8</span> studies · <span>30+</span> readings · <span>20</span> papers 279 + <span>359</span> pieces · <span>76</span> lib modules · <span>94</span> functions · <span>159</span> plans · <span>100</span> reports · <span>8</span> studies · <span>30+</span> readings · <span>26</span> papers 280 280 </div> 281 281 </div> 282 282 ··· 293 293 <div class="section-header" data-color="pink" onclick="toggleSection(this)"> 294 294 <span class="toggle">&#9660;</span> 295 295 <h2>Papers</h2> 296 - <span class="count">20 publications</span> 296 + <span class="count">26 publications</span> 297 297 </div> 298 298 <div class="section-items"> 299 299 <a class="item" href="/aesthetic-computer-26-arxiv.pdf">Aesthetic Computer '26 — A Mobile-First Runtime for Creative Computing (arXiv, 5pp)</a> ··· 439 439 <div class="section-header collapsed" data-color="green" onclick="toggleSection(this)"> 440 440 <span class="toggle">&#9660;</span> 441 441 <h2>Servers & Services</h2> 442 - <span class="count">92 functions · 5 services · 7 session modules</span> 442 + <span class="count">94 functions · 5 services · 7 session modules</span> 443 443 </div> 444 444 <div class="section-items hidden"> 445 445 <div class="item-group"> 446 - <div class="item-group-label">Netlify Functions (92 serverless endpoints)</div> 446 + <div class="item-group-label">Netlify Functions (94 serverless endpoints)</div> 447 447 <a class="item" href="https://github.com/whistlegraph/aesthetic-computer/tree/main/system/netlify/functions"><span class="file">system/netlify/functions/</span> Auth, content, keeps, chat, billing, telemetry, KidLisp storage</a> 448 448 <a class="item" href="https://aesthetic.computer/api/api-docs"><span class="file">api/api-docs</span> LLM-friendly API documentation (live)</a> 449 449 </div> ··· 613 613 <div class="section-header collapsed" data-color="cyan" onclick="toggleSection(this)"> 614 614 <span class="toggle">&#9660;</span> 615 615 <h2>Reports</h2> 616 - <span class="count">95 documents</span> 616 + <span class="count">100 documents</span> 617 617 </div> 618 618 <div class="section-items hidden" id="reports-list"></div> 619 619 </div> ··· 623 623 <div class="section-header collapsed" data-color="gold" onclick="toggleSection(this)"> 624 624 <span class="toggle">&#9660;</span> 625 625 <h2>Plans</h2> 626 - <span class="count">156 documents</span> 626 + <span class="count">159 documents</span> 627 627 </div> 628 628 <div class="section-items hidden" id="plans-list"></div> 629 629 </div> ··· 675 675 <div class="section-header collapsed" data-color="cyan" onclick="toggleSection(this)"> 676 676 <span class="toggle">&#9660;</span> 677 677 <h2>APIs & Data Sources</h2> 678 - <span class="count">92 endpoints · 32+ collections</span> 678 + <span class="count">94 endpoints · 32+ collections</span> 679 679 </div> 680 680 <div class="section-items hidden"> 681 681 <div class="item-group"> ··· 683 683 <a class="item" href="https://aesthetic.computer/api/api-docs">api/api-docs — LLM-friendly API documentation</a> 684 684 <a class="item" href="https://aesthetic.computer/api/version">api/version — platform version</a> 685 685 <a class="item" href="https://aesthetic.computer/api/metrics">api/metrics — platform metrics</a> 686 - <span class="item"><span class="file">92 total</span> Netlify serverless functions (auth, content, keeps, chat, billing, telemetry)</span> 686 + <span class="item"><span class="file">94 total</span> Netlify serverless functions (auth, content, keeps, chat, billing, telemetry)</span> 687 687 </div> 688 688 <div class="item-group"> 689 689 <div class="item-group-label">MongoDB Collections (key)</div> ··· 943 943 ["2026-03-12-jeffrey-kidlisp-sessions-and-keep-report.md","Jeffrey Kidlisp Sessions And Keep Report"], 944 944 ["2026-03-12-jeffrey-kidlisp-canonical-thumb-gallery.md","Jeffrey Kidlisp Canonical Thumb Gallery"], 945 945 ["2026-03-11-keeps-market-report.md","Keeps Market Report"], 946 + ["ac-native-size-analysis.md","Ac Native Size Analysis"], 947 + ["ac-native-init-analysis.md","Ac Native Init Analysis"], 948 + ["ac-native-boot-speed-optimization.md","Ac Native Boot Speed Optimization"], 949 + ["2026-03-23-at-protocol-audit.md","At Protocol Audit"], 950 + ["2026-03-20-hda-capture-eio-investigation.md","Hda Capture Eio Investigation"], 946 951 ]; 947 952 948 953 const plans = [ ··· 1102 1107 ["android-app-screenshots.md","Android App Screenshots"], 1103 1108 ["ac-machines-remote-monitoring.md","Ac Machines Remote Monitoring"], 1104 1109 ["ac-command-robustness.md","Ac Command Robustness"], 1110 + ["rename-keeps-to-keep.md","Rename Keeps To Keep"], 1111 + ["docker-ota-build-pipeline.md","Docker Ota Build Pipeline"], 1112 + ["blank-checkout.md","Blank Checkout"], 1105 1113 ]; 1106 1114 1107 1115 const studies = [