atproto tamogachi (https://bsky.app/profile/cee.wtf/post/3mmf477wius2p)
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("&", "&")
1159 .replaceAll("<", "<")
1160 .replaceAll(">", ">")
1161 .replaceAll('"', """);
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}