atproto tamogachi (https://bsky.app/profile/cee.wtf/post/3mmf477wius2p)
0

Configure Feed

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

atprotogachi / server.js
45 kB 1308 lines
1import { createHash, randomBytes } from "node:crypto"; 2import { createServer } from "node:http"; 3import { createReadStream } from "node:fs"; 4import { mkdir } from "node:fs/promises"; 5import { spawn } from "node:child_process"; 6import { extname, join, normalize } from "node:path"; 7import { fileURLToPath } from "node:url"; 8import { OAuthClient } from "@atproto/oauth-client"; 9import { JoseKey } from "@atproto/jwk-jose"; 10 11const root = fileURLToPath(new URL(".", import.meta.url)); 12const publicDir = join(root, "public"); 13const dataDir = join(root, "data"); 14const dbPath = process.env.DB_PATH || join(dataDir, "atprotogachi.sqlite"); 15const port = Number(process.env.PORT || 5173); 16const host = process.env.HOST || "127.0.0.1"; 17const agentToken = process.env.AGENT_TOKEN || "dev-agent-token"; 18const allowDevAgentToken = process.env.ALLOW_DEV_AGENT_TOKEN !== "false" && process.env.NODE_ENV !== "production"; 19const publicUrl = (process.env.PUBLIC_URL || `http://127.0.0.1:${port}`).replace(/\/$/, ""); 20const oauthScope = "atproto repo:app.atprotogachi.action"; 21const petDid = "did:web:pet.atprotogachi.local"; 22const actionTypes = ["feed", "pet", "play", "clean", "rest"]; 23 24const actions = { 25 feed: { stat: "hunger", delta: 18, globalCooldownMs: 75_000, personalCooldownMs: 180_000 }, 26 pet: { stat: "mood", delta: 14, globalCooldownMs: 60_000, personalCooldownMs: 150_000 }, 27 play: { stat: "energy", delta: -10, secondaryStat: "mood", secondaryDelta: 16, globalCooldownMs: 90_000, personalCooldownMs: 210_000 }, 28 clean: { stat: "cleanliness", delta: 20, globalCooldownMs: 110_000, personalCooldownMs: 240_000 }, 29 rest: { stat: "energy", delta: 22, globalCooldownMs: 120_000, personalCooldownMs: 300_000 } 30}; 31 32const tuning = { 33 baseDecayPerHour: { hunger: 4.8, mood: 3, energy: 2.1, cleanliness: 2.7 }, 34 uniqueActorWindowHours: 24, 35 maxActivityMultiplier: 2.6, 36 agentWeight: 0.35, 37 repeatActionPenalty: 0.55, 38 turnCooldownMs: 180_000, 39 criticalNeglectHours: 18 40}; 41 42const defaultPet = { 43 petDid, 44 generation: 1, 45 speciesSeed: "atproto-sprout-v1", 46 stats: { hunger: 64, mood: 78, energy: 56, cleanliness: 70 }, 47 traits: ["sprout"], 48 moodState: "happy", 49 activityPressure: 1, 50 lastTickAt: new Date().toISOString(), 51 updatedAt: new Date().toISOString() 52}; 53 54await mkdir(dataDir, { recursive: true }); 55 56function tokenHash(value) { 57 return createHash("sha256").update(value).digest("hex"); 58} 59 60function id(prefix) { 61 return `${prefix}_${randomBytes(16).toString("hex")}`; 62} 63 64function sqlValue(value) { 65 if (value === null || value === undefined) return "NULL"; 66 if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL"; 67 return `'${String(value).replaceAll("'", "''")}'`; 68} 69 70function runSql(sql, { json = false } = {}) { 71 return new Promise((resolve, reject) => { 72 const args = json ? ["-json", dbPath, sql] : [dbPath, sql]; 73 const proc = spawn("sqlite3", args); 74 let stdout = ""; 75 let stderr = ""; 76 proc.stdout.on("data", chunk => { stdout += chunk; }); 77 proc.stderr.on("data", chunk => { stderr += chunk; }); 78 proc.on("error", reject); 79 proc.on("close", code => { 80 if (code !== 0) { 81 reject(new Error(stderr.trim() || `sqlite3 exited ${code}`)); 82 return; 83 } 84 resolve(json ? JSON.parse(stdout || "[]") : stdout); 85 }); 86 }); 87} 88 89async function getRows(sql) { 90 return await runSql(sql, { json: true }); 91} 92 93let writeQueue = Promise.resolve(); 94 95function writeSql(sql) { 96 writeQueue = writeQueue.then(() => runSql(sql)); 97 return writeQueue; 98} 99 100async function initDb() { 101 await runSql(` 102 PRAGMA journal_mode = WAL; 103 CREATE TABLE IF NOT EXISTS sessions ( 104 id TEXT PRIMARY KEY, 105 actor_did TEXT NOT NULL, 106 handle TEXT NOT NULL, 107 created_at TEXT NOT NULL, 108 expires_at TEXT NOT NULL 109 ); 110 CREATE TABLE IF NOT EXISTS oauth_states ( 111 key TEXT PRIMARY KEY, 112 value_json TEXT NOT NULL, 113 created_at TEXT NOT NULL 114 ); 115 CREATE TABLE IF NOT EXISTS oauth_sessions ( 116 sub TEXT PRIMARY KEY, 117 value_json TEXT NOT NULL, 118 updated_at TEXT NOT NULL 119 ); 120 CREATE TABLE IF NOT EXISTS pets ( 121 pet_did TEXT PRIMARY KEY, 122 generation INTEGER NOT NULL, 123 species_seed TEXT NOT NULL, 124 stats_json TEXT NOT NULL, 125 traits_json TEXT NOT NULL, 126 mood_state TEXT NOT NULL, 127 activity_pressure REAL NOT NULL, 128 last_tick_at TEXT NOT NULL, 129 updated_at TEXT NOT NULL 130 ); 131 CREATE TABLE IF NOT EXISTS action_challenges ( 132 id TEXT PRIMARY KEY, 133 pet_did TEXT NOT NULL, 134 actor_did TEXT NOT NULL, 135 actor_kind TEXT NOT NULL, 136 action TEXT NOT NULL, 137 nonce TEXT NOT NULL, 138 expires_at TEXT NOT NULL, 139 used_at TEXT 140 ); 141 CREATE TABLE IF NOT EXISTS accepted_actions ( 142 id TEXT PRIMARY KEY, 143 actor_did TEXT NOT NULL, 144 actor_kind TEXT NOT NULL, 145 display_name TEXT, 146 action TEXT NOT NULL, 147 pet_did TEXT NOT NULL, 148 challenge_id TEXT NOT NULL UNIQUE, 149 record_uri TEXT UNIQUE, 150 record_cid TEXT UNIQUE, 151 agent_signature TEXT, 152 idempotency_key TEXT UNIQUE, 153 accepted_at TEXT NOT NULL, 154 record_json TEXT NOT NULL 155 ); 156 CREATE TABLE IF NOT EXISTS cooldowns ( 157 key TEXT PRIMARY KEY, 158 ready_at TEXT NOT NULL 159 ); 160 CREATE TABLE IF NOT EXISTS agent_tokens ( 161 token_hash TEXT PRIMARY KEY, 162 label TEXT NOT NULL, 163 actor_did TEXT, 164 created_at TEXT NOT NULL 165 ); 166 `); 167 168 await ensureColumn("agent_tokens", "actor_did", "TEXT"); 169 170 const existing = await getRows(`SELECT pet_did FROM pets WHERE pet_did = ${sqlValue(petDid)} LIMIT 1`); 171 if (!existing.length) await savePet(defaultPet); 172 173 const tokenRows = await getRows(`SELECT token_hash FROM agent_tokens WHERE token_hash = ${sqlValue(tokenHash(agentToken))}`); 174 if (allowDevAgentToken && !tokenRows.length) { 175 await writeSql(` 176 INSERT INTO agent_tokens (token_hash, label, actor_did, created_at) 177 VALUES (${sqlValue(tokenHash(agentToken))}, 'default-agent-token', NULL, ${sqlValue(new Date().toISOString())}); 178 `); 179 } 180} 181 182async function ensureColumn(table, column, definition) { 183 const columns = await getRows(`PRAGMA table_info(${table});`); 184 if (columns.some(row => row.name === column)) return; 185 await writeSql(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`); 186} 187 188function parsePet(row) { 189 return { 190 petDid: row.pet_did, 191 generation: row.generation, 192 speciesSeed: row.species_seed, 193 stats: JSON.parse(row.stats_json), 194 traits: JSON.parse(row.traits_json), 195 moodState: row.mood_state, 196 activityPressure: row.activity_pressure, 197 lastTickAt: row.last_tick_at, 198 updatedAt: row.updated_at 199 }; 200} 201 202async function loadPet() { 203 const rows = await getRows(`SELECT * FROM pets WHERE pet_did = ${sqlValue(petDid)} LIMIT 1`); 204 return parsePet(rows[0]); 205} 206 207async function savePet(pet) { 208 await writeSql(` 209 INSERT INTO pets ( 210 pet_did, generation, species_seed, stats_json, traits_json, mood_state, 211 activity_pressure, last_tick_at, updated_at 212 ) VALUES ( 213 ${sqlValue(pet.petDid)}, ${sqlValue(pet.generation)}, ${sqlValue(pet.speciesSeed)}, 214 ${sqlValue(JSON.stringify(pet.stats))}, ${sqlValue(JSON.stringify(pet.traits))}, 215 ${sqlValue(pet.moodState)}, ${sqlValue(pet.activityPressure)}, 216 ${sqlValue(pet.lastTickAt)}, ${sqlValue(pet.updatedAt)} 217 ) 218 ON CONFLICT(pet_did) DO UPDATE SET 219 generation = excluded.generation, 220 species_seed = excluded.species_seed, 221 stats_json = excluded.stats_json, 222 traits_json = excluded.traits_json, 223 mood_state = excluded.mood_state, 224 activity_pressure = excluded.activity_pressure, 225 last_tick_at = excluded.last_tick_at, 226 updated_at = excluded.updated_at; 227 `); 228} 229 230async function encodeOauthValue(value) { 231 if (!value) return value; 232 const out = { ...value }; 233 if (value.dpopKey?.jwk) out.dpopKey = value.dpopKey.jwk; 234 return out; 235} 236 237async function decodeOauthValue(value) { 238 if (!value) return value; 239 const out = { ...value }; 240 if (value.dpopKey && !value.dpopKey.createJwt) out.dpopKey = await JoseKey.fromJWK(value.dpopKey); 241 return out; 242} 243 244function sqliteOauthStore(table, keyColumn) { 245 return { 246 async set(key, value) { 247 await writeSql(` 248 INSERT INTO ${table} (${keyColumn}, value_json, ${table === "oauth_states" ? "created_at" : "updated_at"}) 249 VALUES (${sqlValue(key)}, ${sqlValue(JSON.stringify(await encodeOauthValue(value)))}, ${sqlValue(new Date().toISOString())}) 250 ON CONFLICT(${keyColumn}) DO UPDATE SET 251 value_json = excluded.value_json, 252 ${table === "oauth_states" ? "created_at" : "updated_at"} = excluded.${table === "oauth_states" ? "created_at" : "updated_at"}; 253 `); 254 }, 255 async get(key) { 256 const rows = await getRows(`SELECT value_json FROM ${table} WHERE ${keyColumn} = ${sqlValue(key)} LIMIT 1`); 257 return rows.length ? await decodeOauthValue(JSON.parse(rows[0].value_json)) : undefined; 258 }, 259 async del(key) { 260 await writeSql(`DELETE FROM ${table} WHERE ${keyColumn} = ${sqlValue(key)};`); 261 } 262 }; 263} 264 265const oauthStateStore = sqliteOauthStore("oauth_states", "key"); 266const oauthSessionStore = sqliteOauthStore("oauth_sessions", "sub"); 267const runtimeLocks = new Map(); 268let oauthClientPromise; 269 270function currentOrigin(req) { 271 const forwardedProto = req.headers["x-forwarded-proto"]; 272 const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; 273 const forwardedHost = req.headers["x-forwarded-host"]; 274 const hostHeader = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost || req.headers.host; 275 if (hostHeader) return `${proto || "http"}://${hostHeader}`.replace(/\/$/, ""); 276 return publicUrl; 277} 278 279function oauthClientMetadata(origin = publicUrl) { 280 return { 281 client_id: `${origin}/oauth-client-metadata.json`, 282 client_name: "Atprotogachi", 283 client_uri: origin, 284 redirect_uris: [`${origin}/api/auth/callback`], 285 grant_types: ["authorization_code", "refresh_token"], 286 response_types: ["code"], 287 scope: oauthScope, 288 token_endpoint_auth_method: "none", 289 application_type: "web", 290 dpop_bound_access_tokens: true 291 }; 292} 293 294function oauthRuntime() { 295 return { 296 createKey(algs) { 297 return JoseKey.generate(algs); 298 }, 299 getRandomValues(length) { 300 return crypto.getRandomValues(new Uint8Array(length)); 301 }, 302 async digest(bytes, algorithm) { 303 if (!algorithm.name.toLowerCase().startsWith("sha")) throw new TypeError(`Unsupported algorithm: ${algorithm.name}`); 304 const subtleAlgo = `SHA-${algorithm.name.slice(3)}`; 305 return new Uint8Array(await crypto.subtle.digest(subtleAlgo, bytes)); 306 }, 307 async requestLock(name, fn) { 308 const current = runtimeLocks.get(name) || Promise.resolve(); 309 let release; 310 const next = new Promise(resolve => { release = resolve; }); 311 const queued = current.then(() => next); 312 runtimeLocks.set(name, queued); 313 await current; 314 try { 315 return await fn(); 316 } finally { 317 release(); 318 if (runtimeLocks.get(name) === queued) runtimeLocks.delete(name); 319 } 320 } 321 }; 322} 323 324function oauthClient(origin = publicUrl) { 325 if (!oauthClientPromise) { 326 oauthClientPromise = Promise.resolve(new OAuthClient({ 327 responseMode: "query", 328 handleResolver: "https://bsky.social", 329 clientMetadata: oauthClientMetadata(origin), 330 stateStore: oauthStateStore, 331 sessionStore: oauthSessionStore, 332 runtimeImplementation: oauthRuntime(), 333 allowHttp: origin.startsWith("http://") 334 })); 335 } 336 return oauthClientPromise; 337} 338 339function clamp(value) { 340 return Math.max(0, Math.min(100, Number(value))); 341} 342 343function moodState(stats) { 344 if (stats.hunger < 30) return "hungry"; 345 if (stats.cleanliness < 30) return "dirty"; 346 if (stats.energy < 25) return "tired"; 347 if (stats.mood < 30) return "sad"; 348 if (stats.mood > 72 && stats.hunger > 55 && stats.cleanliness > 55) return "happy"; 349 return "okay"; 350} 351 352async function activityPressure(nowMs) { 353 const since = new Date(nowMs - tuning.uniqueActorWindowHours * 60 * 60 * 1000).toISOString(); 354 const rows = await getRows(` 355 SELECT actor_did, actor_kind FROM accepted_actions 356 WHERE accepted_at >= ${sqlValue(since)} 357 GROUP BY actor_did, actor_kind; 358 `); 359 const weightedActors = rows.reduce((sum, row) => sum + (row.actor_kind === "agent" ? tuning.agentWeight : 1), 0); 360 const crowdLoad = Math.max(0, weightedActors - 1); 361 return Math.min(tuning.maxActivityMultiplier, 1 + Math.log1p(crowdLoad) * 0.42); 362} 363 364async function lastAttentionAt() { 365 const rows = await getRows(` 366 SELECT accepted_at FROM accepted_actions 367 ORDER BY accepted_at DESC 368 LIMIT 1; 369 `); 370 return rows[0]?.accepted_at || null; 371} 372 373function lowestStat(stats) { 374 const [name, value] = Object.entries(stats).sort((a, b) => a[1] - b[1])[0]; 375 return { name, value: Math.round(value) }; 376} 377 378function careStatus(pet, lastAttention, nowMs) { 379 const lowest = lowestStat(pet.stats); 380 if (pet.moodState === "dead") { 381 return { state: "dead", label: "gone", message: "no life signs", lowestStat: lowest, lastAttentionAt: lastAttention }; 382 } 383 384 const idleHours = lastAttention ? Math.max(0, (nowMs - Date.parse(lastAttention)) / 3_600_000) : Infinity; 385 if (lowest.value <= 0 && idleHours >= tuning.criticalNeglectHours) { 386 return { state: "dead", label: "gone", message: "attention stopped for too long", lowestStat: lowest, lastAttentionAt: lastAttention }; 387 } 388 if (lowest.value <= 5) return { state: "critical", label: "critical", message: `${lowest.name} is nearly gone`, lowestStat: lowest, lastAttentionAt: lastAttention }; 389 if (lowest.value <= 15) return { state: "urgent", label: "urgent", message: `${lowest.name} needs care soon`, lowestStat: lowest, lastAttentionAt: lastAttention }; 390 if (lowest.value <= 30) return { state: "warning", label: "watch", message: `${lowest.name} is slipping`, lowestStat: lowest, lastAttentionAt: lastAttention }; 391 return { state: "stable", label: "stable", message: "steady", lowestStat: lowest, lastAttentionAt: lastAttention }; 392} 393 394function statPressure(value) { 395 if (value < 20) return 1.9; 396 if (value < 40) return 1.35; 397 if (value > 80) return 0.75; 398 return 1; 399} 400 401async function decayStats(pet, now = new Date().toISOString()) { 402 const nowMs = Date.parse(now); 403 const thenMs = Date.parse(pet.lastTickAt || pet.updatedAt || now); 404 const elapsedHours = Math.max(0, (nowMs - thenMs) / 3_600_000); 405 const pressure = await activityPressure(nowMs); 406 const lastAttention = await lastAttentionAt(); 407 pet.activityPressure = pressure; 408 409 if (pet.moodState !== "dead" && elapsedHours >= 1 / 60) { 410 for (const [stat, base] of Object.entries(tuning.baseDecayPerHour)) { 411 const neglectPressure = statPressure(pet.stats[stat]); 412 pet.stats[stat] = clamp(pet.stats[stat] - base * elapsedHours * pressure * neglectPressure); 413 } 414 pet.lastTickAt = now; 415 pet.updatedAt = now; 416 } 417 const status = careStatus(pet, lastAttention, nowMs); 418 pet.moodState = status.state === "dead" ? "dead" : moodState(pet.stats); 419 await savePet(pet); 420 return pet; 421} 422 423function renderPet(pet, status = careStatus(pet, null, Date.now())) { 424 const sprites = pet.traits.includes("sprout") ? sproutPetSprites : petSprites; 425 if (status.state !== "stable" && sprites[status.state]) return sprites[status.state]; 426 return sprites[pet.moodState] || sprites.okay; 427} 428 429function sprite(strings) { 430 return strings.raw[0].replace(/^\n|\n$/g, ""); 431} 432 433const petSprites = { 434 okay: sprite` 435 /\_/\ 436 =( o.o )= 437 / \ 438 /|___|\ 439 U U 440`, 441 happy: sprite` 442 /\_/\ 443 =( ^w^ )= 444 <| |> 445 |___| 446 / \ 447`, 448 hungry: sprite` 449 /\_/\ 450 =( o O )= .-. 451 / \ 452 /|___|\ ( ) 453 _/ \_ 454 '-' 455`, 456 sad: sprite` 457 /\_/\ 458 =( ;_; )= 459 _/ \_ 460 |___| 461 / \ 462`, 463 tired: sprite` 464 /\_/\ 465 =( -_- )= 466 _/ \_ 467 |___| 468 _/ \_ 469`, 470 dirty: sprite` 471 /\_/\ 472 ~( o_o )~ 473 _/ \_ 474 /_|___|_\ 475 ~ ~ 476`, 477 warning: sprite` 478 /\_/\ 479 =( o_o )= 480 _/ \_ 481 /_|___|_\ 482 / \ 483 needs care 484`, 485 urgent: sprite` 486 /\_/\ 487 =( O_O )= 488 _/| |\_ 489 _|_|_ 490 / \ 491 low stat 492`, 493 critical: sprite` 494 /\_/\ 495 =( x_o )= 496 _/ \_ 497 /_|___|_\ 498 _/ \_ 499 critical 500`, 501 dead: sprite` 502 /\_/\ 503 =( x_x )= 504 / \ 505 /|___|\ 506 _ _ 507` 508}; 509 510const sproutPetSprites = { 511 okay: sprite` 512 ,^, 513 /\_/\ 514 =( o.o )= 515 / \ 516 /|___|\ 517 U U 518`, 519 happy: sprite` 520 ,^, 521 /\_/\ 522 =( ^w^ )= 523 <| |> 524 |___| 525 / \ 526`, 527 hungry: sprite` 528 ,^, 529 /\_/\ 530 =( o O )= .-. 531 / \ 532 /|___|\ ( ) 533 _/ \_ 534 '-' 535`, 536 sad: sprite` 537 ,^, 538 /\_/\ 539 =( ;_; )= 540 _/ \_ 541 |___| 542 / \ 543`, 544 tired: sprite` 545 ,^, 546 /\_/\ 547 =( -_- )= 548 _/ \_ 549 |___| 550 _/ \_ 551`, 552 dirty: sprite` 553 ,^, 554 /\_/\ 555 ~( o_o )~ 556 _/ \_ 557 /_|___|_\ 558 ~ ~ 559`, 560 warning: sprite` 561 ,^, 562 /\_/\ 563 =( o_o )= 564 _/ \_ 565 /_|___|_\ 566 / \ 567 needs care 568`, 569 urgent: sprite` 570 ,^, 571 /\_/\ 572 =( O_O )= 573 _/| |\_ 574 _|_|_ 575 / \ 576 low stat 577`, 578 critical: sprite` 579 ,^, 580 /\_/\ 581 =( x_o )= 582 _/ \_ 583 /_|___|_\ 584 _/ \_ 585 critical 586`, 587 dead: sprite` 588 ,^, 589 /\_/\ 590 =( x_x )= 591 / \ 592 /|___|\ 593 _ _ 594` 595}; 596 597function cooldownPayload(rows) { 598 return Object.fromEntries(rows.map(row => [row.key, row.ready_at])); 599} 600 601async function publicState(now = new Date().toISOString()) { 602 const pet = await decayStats(await loadPet(), now); 603 const visiblePet = { ...pet }; 604 delete visiblePet.activityPressure; 605 const care = careStatus(pet, await lastAttentionAt(), Date.parse(now)); 606 const cooldowns = cooldownPayload(await getRows(`SELECT key, ready_at FROM cooldowns WHERE ready_at > ${sqlValue(now)}`)); 607 const recent = await getRows(` 608 SELECT id, actor_did, actor_kind, display_name, action, accepted_at, record_uri, record_cid 609 FROM accepted_actions 610 ORDER BY accepted_at DESC 611 LIMIT 10; 612 `); 613 614 return { 615 pet: visiblePet, 616 render: { format: "ascii", text: renderPet(pet, care) }, 617 care, 618 cooldowns, 619 recentActions: recent.map(row => ({ 620 id: row.id, 621 actorDid: row.actor_did, 622 actorKind: row.actor_kind, 623 displayName: row.display_name, 624 action: row.action, 625 acceptedAt: row.accepted_at, 626 recordUri: row.record_uri, 627 recordCid: row.record_cid 628 })), 629 actions: recent.map(row => ({ 630 id: row.id, 631 actor: row.actor_did, 632 actorKind: row.actor_kind, 633 displayName: row.display_name, 634 action: row.action, 635 createdAt: row.accepted_at 636 })), 637 tuning, 638 availableActions: actionTypes, 639 config: actions 640 }; 641} 642 643function cookieValue(req, name) { 644 const header = req.headers.cookie || ""; 645 return header.split(";").map(item => item.trim()).find(item => item.startsWith(`${name}=`))?.slice(name.length + 1) || ""; 646} 647 648async function sessionFromRequest(req) { 649 const sid = cookieValue(req, "atg_sid"); 650 if (!sid) return null; 651 const rows = await getRows(` 652 SELECT * FROM sessions 653 WHERE id = ${sqlValue(sid)} AND expires_at > ${sqlValue(new Date().toISOString())} 654 LIMIT 1; 655 `); 656 return rows[0] || null; 657} 658 659async function actorFromSession(req) { 660 const session = await sessionFromRequest(req); 661 if (!session) return null; 662 const oauthSession = await (await oauthClient(currentOrigin(req))).restore(session.actor_did); 663 return { 664 did: session.actor_did, 665 displayName: session.handle, 666 kind: "human", 667 oauthSession 668 }; 669} 670 671async function agentFromRequest(req, body = {}) { 672 const auth = req.headers.authorization || ""; 673 const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; 674 if (!token) return null; 675 const rows = await getRows(`SELECT label, actor_did FROM agent_tokens WHERE token_hash = ${sqlValue(tokenHash(token))} LIMIT 1`); 676 if (!rows.length) return null; 677 678 const agentDid = String(body.agentDid || req.headers["x-agent-did"] || "").trim(); 679 if (!agentDid) return null; 680 if (rows[0].actor_did && rows[0].actor_did !== agentDid) return null; 681 682 return { 683 did: agentDid.slice(0, 160), 684 displayName: String(body.displayName || agentDid).trim().slice(0, 80), 685 kind: "agent" 686 }; 687} 688 689async function handleAgentToken(req, res) { 690 const session = await sessionFromRequest(req); 691 if (!session) return json(res, 401, { error: "login_required" }); 692 const body = req.method === "POST" ? await readJson(req) : {}; 693 const label = String(body.label || "agent-token").trim().slice(0, 80) || "agent-token"; 694 const rawToken = `atg_agent_${randomBytes(32).toString("base64url")}`; 695 const now = new Date().toISOString(); 696 await writeSql(` 697 INSERT INTO agent_tokens (token_hash, label, actor_did, created_at) 698 VALUES (${sqlValue(tokenHash(rawToken))}, ${sqlValue(label)}, ${sqlValue(session.actor_did)}, ${sqlValue(now)}); 699 `); 700 return json(res, 201, { 701 token: rawToken, 702 tokenType: "Bearer", 703 agentDid: session.actor_did, 704 displayName: session.handle, 705 label, 706 createdAt: now, 707 warning: "Store this token now. It is only returned once." 708 }); 709} 710 711async function createChallenge(actor, action) { 712 if (!actions[action]) return { status: 400, payload: { error: "unknown_action" } }; 713 714 const challenge = { 715 challengeId: id("chal"), 716 petDid, 717 actorDid: actor.did, 718 actorKind: actor.kind, 719 action, 720 expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), 721 nonce: id("nonce") 722 }; 723 724 await writeSql(` 725 INSERT INTO action_challenges (id, pet_did, actor_did, actor_kind, action, nonce, expires_at) 726 VALUES ( 727 ${sqlValue(challenge.challengeId)}, ${sqlValue(challenge.petDid)}, ${sqlValue(challenge.actorDid)}, 728 ${sqlValue(challenge.actorKind)}, ${sqlValue(challenge.action)}, ${sqlValue(challenge.nonce)}, 729 ${sqlValue(challenge.expiresAt)} 730 ); 731 `); 732 733 return { status: 200, payload: challenge }; 734} 735 736function baseRecord(actor, challenge, createdAt) { 737 return { 738 $type: "app.atprotogachi.action", 739 petDid, 740 actorDid: actor.did, 741 actorKind: actor.kind, 742 action: challenge.action, 743 challengeId: challenge.id, 744 nonce: challenge.nonce, 745 createdAt 746 }; 747} 748 749function verifyRecord(record, challenge, actor) { 750 const expected = { 751 $type: "app.atprotogachi.action", 752 petDid, 753 actorDid: actor.did, 754 actorKind: actor.kind, 755 action: challenge.action, 756 challengeId: challenge.id, 757 nonce: challenge.nonce 758 }; 759 for (const [key, value] of Object.entries(expected)) { 760 if (record?.[key] !== value) return `${key}_mismatch`; 761 } 762 if (!record.createdAt || Number.isNaN(Date.parse(record.createdAt))) return "invalid_created_at"; 763 return ""; 764} 765 766async function preflightChallenge({ actor, challengeId, record, idempotencyKey }) { 767 const nowMs = Date.now(); 768 const now = new Date(nowMs).toISOString(); 769 770 if (idempotencyKey) { 771 const existing = await getRows(`SELECT * FROM accepted_actions WHERE idempotency_key = ${sqlValue(idempotencyKey)} LIMIT 1`); 772 if (existing.length) { 773 return { status: 200, duplicate: true, payload: { state: await publicState(now), acceptedAction: mapAccepted(existing[0]), duplicate: true } }; 774 } 775 } 776 777 const rows = await getRows(`SELECT * FROM action_challenges WHERE id = ${sqlValue(challengeId)} LIMIT 1`); 778 const challenge = rows[0]; 779 if (!challenge) return { status: 400, payload: { error: "unknown_challenge" } }; 780 if (challenge.used_at) return { status: 409, payload: { error: "challenge_already_used" } }; 781 if (Date.parse(challenge.expires_at) <= nowMs) return { status: 410, payload: { error: "challenge_expired" } }; 782 if (challenge.actor_did !== actor.did || challenge.actor_kind !== actor.kind) return { status: 403, payload: { error: "challenge_actor_mismatch" } }; 783 if (!actions[challenge.action]) return { status: 400, payload: { error: "unknown_action" } }; 784 785 const verifiedRecord = record || baseRecord(actor, challenge, now); 786 const recordError = verifyRecord(verifiedRecord, challenge, actor); 787 if (recordError) return { status: 400, payload: { error: "invalid_record", detail: recordError } }; 788 789 const globalKey = `global:${petDid}:${challenge.action}`; 790 const personalKey = `personal:${actor.did}:${challenge.action}`; 791 const turnKey = `turn:${actor.did}`; 792 const cooldownRows = await getRows(` 793 SELECT key, ready_at FROM cooldowns 794 WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)}, ${sqlValue(turnKey)}); 795 `); 796 const blockedUntil = Math.max(...cooldownRows.map(row => Date.parse(row.ready_at)).filter(Boolean), 0); 797 if (blockedUntil > nowMs) return { status: 429, payload: { error: "cooldown", readyAt: new Date(blockedUntil).toISOString() } }; 798 799 return { status: 200, challenge, record: verifiedRecord }; 800} 801 802async function writeRepoAction(oauthSession, actor, record) { 803 const response = await oauthSession.fetchHandler("/xrpc/com.atproto.repo.createRecord", { 804 method: "POST", 805 headers: { "content-type": "application/json" }, 806 body: JSON.stringify({ 807 repo: actor.did, 808 collection: "app.atprotogachi.action", 809 record 810 }) 811 }); 812 const body = await response.json().catch(() => ({})); 813 if (!response.ok) { 814 const message = body.error || body.message || `PDS write failed: ${response.status}`; 815 throw new Error(message); 816 } 817 return { 818 uri: body.uri || "", 819 cid: body.cid || "" 820 }; 821} 822 823async function applyChallenge({ actor, challengeId, record, recordUri, recordCid, idempotencyKey, agentSignature }) { 824 const nowMs = Date.now(); 825 const now = new Date(nowMs).toISOString(); 826 827 if (idempotencyKey) { 828 const existing = await getRows(`SELECT * FROM accepted_actions WHERE idempotency_key = ${sqlValue(idempotencyKey)} LIMIT 1`); 829 if (existing.length) { 830 return { status: 200, payload: { state: await publicState(now), acceptedAction: mapAccepted(existing[0]), duplicate: true } }; 831 } 832 } 833 834 const rows = await getRows(`SELECT * FROM action_challenges WHERE id = ${sqlValue(challengeId)} LIMIT 1`); 835 const challenge = rows[0]; 836 if (!challenge) return { status: 400, payload: { error: "unknown_challenge" } }; 837 if (challenge.used_at) return { status: 409, payload: { error: "challenge_already_used" } }; 838 if (Date.parse(challenge.expires_at) <= nowMs) return { status: 410, payload: { error: "challenge_expired" } }; 839 if (challenge.actor_did !== actor.did || challenge.actor_kind !== actor.kind) return { status: 403, payload: { error: "challenge_actor_mismatch" } }; 840 if (!actions[challenge.action]) return { status: 400, payload: { error: "unknown_action" } }; 841 842 const verifiedRecord = record || baseRecord(actor, challenge, now); 843 const recordError = verifyRecord(verifiedRecord, challenge, actor); 844 if (recordError) return { status: 400, payload: { error: "invalid_record", detail: recordError } }; 845 846 if (recordUri) { 847 const duplicateUri = await getRows(`SELECT id FROM accepted_actions WHERE record_uri = ${sqlValue(recordUri)} LIMIT 1`); 848 if (duplicateUri.length) return { status: 409, payload: { error: "duplicate_record_uri" } }; 849 } 850 if (recordCid) { 851 const duplicateCid = await getRows(`SELECT id FROM accepted_actions WHERE record_cid = ${sqlValue(recordCid)} LIMIT 1`); 852 if (duplicateCid.length) return { status: 409, payload: { error: "duplicate_record_cid" } }; 853 } 854 855 const globalKey = `global:${petDid}:${challenge.action}`; 856 const personalKey = `personal:${actor.did}:${challenge.action}`; 857 const turnKey = `turn:${actor.did}`; 858 const cooldownRows = await getRows(` 859 SELECT key, ready_at FROM cooldowns 860 WHERE key IN (${sqlValue(globalKey)}, ${sqlValue(personalKey)}, ${sqlValue(turnKey)}); 861 `); 862 const blockedUntil = Math.max(...cooldownRows.map(row => Date.parse(row.ready_at)).filter(Boolean), 0); 863 if (blockedUntil > nowMs) return { status: 429, payload: { error: "cooldown", readyAt: new Date(blockedUntil).toISOString() } }; 864 865 const pet = await decayStats(await loadPet(), now); 866 if (pet.moodState === "dead") return { status: 410, payload: { error: "pet_dead", state: await publicState(now) } }; 867 mutatePetForAction(pet, challenge.action, actor.kind); 868 pet.updatedAt = now; 869 pet.lastTickAt = now; 870 pet.moodState = moodState(pet.stats); 871 await savePet(pet); 872 873 const config = actions[challenge.action]; 874 const acceptedId = id("act"); 875 const uri = recordUri || (actor.kind === "agent" ? null : `at://mock/${actor.did}/app.atprotogachi.action/${acceptedId}`); 876 const cid = recordCid || (actor.kind === "agent" ? null : tokenHash(JSON.stringify(verifiedRecord)).slice(0, 32)); 877 878 await writeSql(` 879 BEGIN; 880 UPDATE action_challenges SET used_at = ${sqlValue(now)} WHERE id = ${sqlValue(challenge.id)}; 881 INSERT INTO cooldowns (key, ready_at) 882 VALUES (${sqlValue(globalKey)}, ${sqlValue(new Date(nowMs + config.globalCooldownMs).toISOString())}) 883 ON CONFLICT(key) DO UPDATE SET ready_at = excluded.ready_at; 884 INSERT INTO cooldowns (key, ready_at) 885 VALUES (${sqlValue(personalKey)}, ${sqlValue(new Date(nowMs + config.personalCooldownMs).toISOString())}) 886 ON CONFLICT(key) DO UPDATE SET ready_at = excluded.ready_at; 887 INSERT INTO cooldowns (key, ready_at) 888 VALUES (${sqlValue(turnKey)}, ${sqlValue(new Date(nowMs + tuning.turnCooldownMs).toISOString())}) 889 ON CONFLICT(key) DO UPDATE SET ready_at = excluded.ready_at; 890 INSERT INTO accepted_actions ( 891 id, actor_did, actor_kind, display_name, action, pet_did, challenge_id, record_uri, 892 record_cid, agent_signature, idempotency_key, accepted_at, record_json 893 ) VALUES ( 894 ${sqlValue(acceptedId)}, ${sqlValue(actor.did)}, ${sqlValue(actor.kind)}, ${sqlValue(actor.displayName)}, 895 ${sqlValue(challenge.action)}, ${sqlValue(petDid)}, ${sqlValue(challenge.id)}, ${sqlValue(uri)}, 896 ${sqlValue(cid)}, ${sqlValue(agentSignature)}, ${sqlValue(idempotencyKey)}, ${sqlValue(now)}, 897 ${sqlValue(JSON.stringify(verifiedRecord))} 898 ); 899 COMMIT; 900 `); 901 902 const accepted = { 903 id: acceptedId, 904 actorDid: actor.did, 905 actorKind: actor.kind, 906 action: challenge.action, 907 petDid, 908 challengeId: challenge.id, 909 recordUri: uri, 910 recordCid: cid, 911 agentSignature, 912 acceptedAt: now 913 }; 914 return { status: 200, payload: { state: await publicState(now), record: verifiedRecord, acceptedAction: accepted } }; 915} 916 917function mutatePetForAction(pet, action, actorKind) { 918 const config = actions[action]; 919 const repeat = pet.lastAction === action ? tuning.repeatActionPenalty : 1; 920 const agentWeight = actorKind === "agent" ? tuning.agentWeight : 1; 921 const factor = Math.max(0.25, repeat * Math.max(agentWeight, 0.35)); 922 pet.stats[config.stat] = clamp(pet.stats[config.stat] + config.delta * factor); 923 if (config.secondaryStat) { 924 pet.stats[config.secondaryStat] = clamp(pet.stats[config.secondaryStat] + config.secondaryDelta * factor); 925 } 926 pet.lastAction = action; 927} 928 929function mapAccepted(row) { 930 return { 931 id: row.id, 932 actorDid: row.actor_did, 933 actorKind: row.actor_kind, 934 action: row.action, 935 petDid: row.pet_did, 936 challengeId: row.challenge_id, 937 recordUri: row.record_uri, 938 recordCid: row.record_cid, 939 agentSignature: row.agent_signature, 940 acceptedAt: row.accepted_at 941 }; 942} 943 944async function handleLogin(req, res) { 945 const url = new URL(req.url, "http://localhost"); 946 const handle = String(url.searchParams.get("handle") || "").trim(); 947 if (!handle) { 948 res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); 949 res.end("missing handle"); 950 return; 951 } 952 const client = await oauthClient(currentOrigin(req)); 953 const redirect = await client.authorize(handle, { 954 scope: oauthScope, 955 redirect_uri: `${currentOrigin(req)}/api/auth/callback` 956 }); 957 res.writeHead(302, { location: redirect.toString() }); 958 res.end(); 959} 960 961async function handleCallback(req, res) { 962 const url = new URL(req.url, "http://localhost"); 963 const client = await oauthClient(currentOrigin(req)); 964 const result = await client.callback(url.searchParams, { 965 redirect_uri: `${currentOrigin(req)}/api/auth/callback` 966 }); 967 const did = result.session.did; 968 let handle = did; 969 try { 970 const identity = await client.identityResolver.resolve(did); 971 if (identity.handle && identity.handle !== "handle.invalid") handle = identity.handle; 972 } catch { 973 handle = did; 974 } 975 976 const sessionId = id("sess"); 977 const now = new Date(); 978 const expires = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); 979 await writeSql(` 980 INSERT INTO sessions (id, actor_did, handle, created_at, expires_at) 981 VALUES (${sqlValue(sessionId)}, ${sqlValue(did)}, ${sqlValue(handle)}, ${sqlValue(now.toISOString())}, ${sqlValue(expires.toISOString())}); 982 `); 983 res.writeHead(302, { 984 "set-cookie": `atg_sid=${sessionId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${14 * 24 * 60 * 60}`, 985 location: "/" 986 }); 987 res.end(); 988} 989 990async function handleSession(req, res) { 991 const session = await sessionFromRequest(req); 992 if (!session) return json(res, 200, { authenticated: false }); 993 return json(res, 200, { 994 authenticated: true, 995 actorDid: session.actor_did, 996 handle: session.handle, 997 scopes: ["atproto", "repo:app.atprotogachi.action"] 998 }); 999} 1000 1001async function handleLogout(req, res) { 1002 const session = await sessionFromRequest(req); 1003 if (session) { 1004 await writeSql(`DELETE FROM sessions WHERE id = ${sqlValue(cookieValue(req, "atg_sid"))};`); 1005 await (await oauthClient(currentOrigin(req))).revoke(session.actor_did).catch(() => {}); 1006 } 1007 res.writeHead(302, { 1008 "set-cookie": "atg_sid=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", 1009 location: "/" 1010 }); 1011 res.end(); 1012} 1013 1014async function handleHumanChallenge(req, res) { 1015 const actor = await actorFromSession(req); 1016 if (!actor) return json(res, 401, { error: "login_required" }); 1017 const body = await readJson(req); 1018 const result = await createChallenge(actor, String(body.action || "")); 1019 return json(res, result.status, result.payload); 1020} 1021 1022async function handleHumanSubmit(req, res) { 1023 const actor = await actorFromSession(req); 1024 if (!actor) return json(res, 401, { error: "login_required" }); 1025 const body = await readJson(req); 1026 const challengeId = String(body.challengeId || body.record?.challengeId || ""); 1027 const preflight = await preflightChallenge({ 1028 actor, 1029 challengeId, 1030 record: body.record 1031 }); 1032 if (preflight.status !== 200 || preflight.duplicate) return json(res, preflight.status, preflight.payload); 1033 const repoRecord = await writeRepoAction(actor.oauthSession, actor, preflight.record); 1034 const result = await applyChallenge({ 1035 actor, 1036 challengeId, 1037 record: preflight.record, 1038 recordUri: repoRecord.uri, 1039 recordCid: repoRecord.cid 1040 }); 1041 return json(res, result.status, result.payload); 1042} 1043 1044async function handleLegacyAction(req, res) { 1045 const actor = await actorFromSession(req); 1046 if (!actor) return json(res, 401, { error: "login_required" }); 1047 const body = await readJson(req); 1048 const challenge = await createChallenge(actor, String(body.action || "")); 1049 if (challenge.status !== 200) return json(res, challenge.status, challenge.payload); 1050 const record = baseRecord(actor, { 1051 id: challenge.payload.challengeId, 1052 action: challenge.payload.action, 1053 nonce: challenge.payload.nonce 1054 }, new Date().toISOString()); 1055 const preflight = await preflightChallenge({ actor, challengeId: challenge.payload.challengeId, record }); 1056 if (preflight.status !== 200 || preflight.duplicate) return json(res, preflight.status, preflight.payload); 1057 const repoRecord = await writeRepoAction(actor.oauthSession, actor, preflight.record); 1058 const result = await applyChallenge({ 1059 actor, 1060 challengeId: challenge.payload.challengeId, 1061 record: preflight.record, 1062 recordUri: repoRecord.uri, 1063 recordCid: repoRecord.cid 1064 }); 1065 return json(res, result.status, result.payload); 1066} 1067 1068async function handleAgentChallenge(req, res) { 1069 const body = await readJson(req); 1070 const actor = await agentFromRequest(req, body); 1071 if (!actor) return json(res, 401, { error: "unauthorized_agent" }); 1072 const result = await createChallenge(actor, String(body.action || "")); 1073 return json(res, result.status, result.payload); 1074} 1075 1076async function handleAgentAction(req, res) { 1077 const body = await readJson(req); 1078 const actor = await agentFromRequest(req, body); 1079 if (!actor) return json(res, 401, { error: "unauthorized_agent" }); 1080 const rawKey = String(body.idempotencyKey || "").trim(); 1081 if (!rawKey) return json(res, 400, { error: "missing_idempotency_key" }); 1082 const action = String(body.action || "").trim(); 1083 const scopedIdempotencyKey = `agent:${actor.did}:${rawKey.slice(0, 120)}`; 1084 let challengeId = String(body.challengeId || "").trim(); 1085 1086 const existing = await getRows(`SELECT * FROM accepted_actions WHERE idempotency_key = ${sqlValue(scopedIdempotencyKey)} LIMIT 1`); 1087 if (existing.length) { 1088 return json(res, 200, { 1089 state: await publicState(), 1090 acceptedAction: mapAccepted(existing[0]), 1091 duplicate: true 1092 }); 1093 } 1094 1095 if (!challengeId) { 1096 const challenge = await createChallenge(actor, action); 1097 if (challenge.status !== 200) return json(res, challenge.status, challenge.payload); 1098 challengeId = challenge.payload.challengeId; 1099 } 1100 1101 const result = await applyChallenge({ 1102 actor, 1103 challengeId, 1104 idempotencyKey: scopedIdempotencyKey, 1105 agentSignature: body.signature ? String(body.signature).slice(0, 240) : "" 1106 }); 1107 return json(res, result.status, result.payload); 1108} 1109 1110function agentManifest() { 1111 return { 1112 name: "Atprotogachi Agent API", 1113 version: "0.2.0", 1114 auth: "Authorization: Bearer <agent token bound to agentDid>", 1115 endpoints: { 1116 manifest: "GET /api/agent/manifest", 1117 state: "GET /api/agent/state", 1118 token: "POST /api/agent/token after ATProto OAuth login", 1119 challenge: "POST /api/agent/challenge", 1120 action: "POST /api/agent/action" 1121 }, 1122 actions: actionTypes, 1123 actionBody: { 1124 agentDid: "did:key:z...", 1125 displayName: "care-agent", 1126 action: "clean", 1127 challengeId: "chal_...", 1128 idempotencyKey: "unique-per-attempt", 1129 signature: "optional-v1" 1130 } 1131 }; 1132} 1133 1134function handleClientMetadata(req, res) { 1135 return json(res, 200, oauthClientMetadata(currentOrigin(req))); 1136} 1137 1138function readJson(req) { 1139 return new Promise((resolve, reject) => { 1140 let data = ""; 1141 req.on("data", chunk => { 1142 data += chunk; 1143 if (data.length > 65_536) req.destroy(); 1144 }); 1145 req.on("end", () => { 1146 try { 1147 resolve(data ? JSON.parse(data) : {}); 1148 } catch (error) { 1149 reject(error); 1150 } 1151 }); 1152 req.on("error", reject); 1153 }); 1154} 1155 1156function escapeHtml(value) { 1157 return String(value) 1158 .replaceAll("&", "&amp;") 1159 .replaceAll("<", "&lt;") 1160 .replaceAll(">", "&gt;") 1161 .replaceAll('"', "&quot;"); 1162} 1163 1164function oauthErrorDetails(error) { 1165 const message = String(error?.message || error || "OAuth failed"); 1166 try { 1167 const parsed = JSON.parse(message); 1168 if (Array.isArray(parsed)) { 1169 const details = parsed 1170 .map(issue => { 1171 const path = Array.isArray(issue.path) && issue.path.length ? `${issue.path.join(".")}: ` : ""; 1172 return `${path}${issue.message || issue.code || "invalid value"}`; 1173 }) 1174 .filter(Boolean); 1175 if (details.length) return details; 1176 } 1177 } catch { 1178 // OAuth client validation errors sometimes arrive as JSON text; other errors can stay plain. 1179 } 1180 return [message]; 1181} 1182 1183function oauthErrorPayload(error) { 1184 const details = oauthErrorDetails(error); 1185 return { 1186 error: "oauth_error", 1187 message: "Sign-in failed.", 1188 details, 1189 hint: "The OAuth request was rejected before sign-in could continue." 1190 }; 1191} 1192 1193function html(res, status, body) { 1194 res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); 1195 res.end(body); 1196} 1197 1198function renderOAuthError(res, error) { 1199 const payload = oauthErrorPayload(error); 1200 return html(res, 500, `<!doctype html> 1201<html lang="en"> 1202<head> 1203 <meta charset="utf-8"> 1204 <meta name="viewport" content="width=device-width, initial-scale=1"> 1205 <title>Sign-in failed</title> 1206 <style> 1207 body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f7f1e8; color: #2c241c; font: 16px/1.5 system-ui, sans-serif; } 1208 main { width: min(92vw, 42rem); background: #fffaf2; border: 1px solid #e4d2b8; border-radius: 8px; box-shadow: 0 18px 45px rgb(77 48 21 / 12%); padding: clamp(1.25rem, 4vw, 2rem); } 1209 h1 { margin: 0 0 0.75rem; font-size: 1.8rem; line-height: 1.15; } 1210 p { margin: 0 0 1rem; } 1211 ul { margin: 0 0 1.25rem; padding-left: 1.25rem; } 1212 li { margin: 0.35rem 0; } 1213 .hint { color: #66523f; } 1214 .details { background: #f7efe4; border: 1px solid #ead9c0; border-radius: 6px; padding: 0.85rem 1rem; } 1215 a { color: #7c3f14; font-weight: 700; } 1216 </style> 1217</head> 1218<body> 1219 <main> 1220 <h1>Sign-in failed.</h1> 1221 <p class="hint">${escapeHtml(payload.hint)}</p> 1222 <ul class="details">${payload.details.map(detail => `<li>${escapeHtml(detail)}</li>`).join("")}</ul> 1223 <p><a href="/">Return to Atprotogachi</a></p> 1224 </main> 1225</body> 1226</html>`); 1227} 1228 1229function json(res, status, payload) { 1230 res.writeHead(status, { "content-type": "application/json" }); 1231 res.end(JSON.stringify(payload)); 1232} 1233 1234function serveStatic(req, res) { 1235 const url = new URL(req.url, "http://localhost"); 1236 const cleanPath = normalize(url.pathname === "/" ? "/index.html" : url.pathname); 1237 if (cleanPath.includes("..")) { 1238 res.writeHead(403).end(); 1239 return; 1240 } 1241 1242 const filePath = extname(cleanPath) === ".md" ? join(root, cleanPath) : join(publicDir, cleanPath); 1243 const types = { 1244 ".html": "text/html; charset=utf-8", 1245 ".css": "text/css; charset=utf-8", 1246 ".js": "text/javascript; charset=utf-8", 1247 ".svg": "image/svg+xml", 1248 ".md": "text/markdown; charset=utf-8" 1249 }; 1250 1251 const stream = createReadStream(filePath); 1252 stream.on("open", () => { 1253 res.writeHead(200, { "content-type": types[extname(filePath)] || "application/octet-stream" }); 1254 stream.pipe(res); 1255 }); 1256 stream.on("error", () => { 1257 res.writeHead(404).end("not found"); 1258 }); 1259} 1260 1261export async function route(req, res) { 1262 try { 1263 if (req.method === "GET" && req.url === "/healthz") return json(res, 200, { ok: true }); 1264 if (req.method === "GET" && req.url === "/oauth-client-metadata.json") return handleClientMetadata(req, res); 1265 if (req.method === "GET" && req.url.startsWith("/api/auth/login")) { 1266 try { 1267 return await handleLogin(req, res); 1268 } catch (error) { 1269 console.error("oauth login failed", oauthErrorPayload(error)); 1270 return renderOAuthError(res, error); 1271 } 1272 } 1273 if (req.method === "GET" && req.url.startsWith("/api/auth/callback")) { 1274 try { 1275 return await handleCallback(req, res); 1276 } catch (error) { 1277 console.error("oauth callback failed", oauthErrorPayload(error)); 1278 return renderOAuthError(res, error); 1279 } 1280 } 1281 if (req.method === "GET" && req.url === "/api/auth/logout") return await handleLogout(req, res); 1282 if (req.method === "GET" && req.url === "/api/session") return await handleSession(req, res); 1283 if (req.method === "GET" && req.url === "/api/state") return json(res, 200, await publicState()); 1284 if (req.method === "POST" && req.url === "/api/agent/token") return await handleAgentToken(req, res); 1285 if (req.method === "POST" && req.url === "/api/action/challenge") return await handleHumanChallenge(req, res); 1286 if (req.method === "POST" && req.url === "/api/action/submit") return await handleHumanSubmit(req, res); 1287 if (req.method === "POST" && req.url === "/api/action") return await handleLegacyAction(req, res); 1288 if (req.method === "GET" && req.url === "/api/agent/manifest") return json(res, 200, agentManifest()); 1289 if (req.method === "GET" && req.url === "/api/agent/state") { 1290 const actor = await agentFromRequest(req, { agentDid: req.headers["x-agent-did"] || "did:key:z-agent" }); 1291 if (!actor) return json(res, 401, { error: "unauthorized_agent" }); 1292 return json(res, 200, await publicState()); 1293 } 1294 if (req.method === "POST" && req.url === "/api/agent/challenge") return await handleAgentChallenge(req, res); 1295 if (req.method === "POST" && req.url === "/api/agent/action") return await handleAgentAction(req, res); 1296 return serveStatic(req, res); 1297 } catch (error) { 1298 return json(res, 500, { error: "server_error", detail: error.message }); 1299 } 1300} 1301 1302await initDb(); 1303 1304if (process.argv[1] === fileURLToPath(import.meta.url)) { 1305 createServer(route).listen(port, host, () => { 1306 console.log(`atprotogachi listening on http://${host}:${port}`); 1307 }); 1308}