WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: add detailed implementation plan for ATB-12

+1205
+1205
docs/plans/2026-02-09-write-endpoints-implementation.md
···
··· 1 + # Write-Path API Endpoints Implementation Plan (ATB-12) 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build POST /api/topics and POST /api/posts endpoints that write records to users' PDS servers via OAuth-authenticated agents. 6 + 7 + **Architecture:** Thin proxy endpoints that validate input, query database for parent/root validation (replies only), construct AT Protocol records, write to user's PDS via `agent.com.atproto.repo.putRecord()`, and return immediately. Firehose indexer picks up records asynchronously. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM, @atproto/api (UnicodeString, Agent), @atproto/common-web (TID), OAuth middleware from ATB-14 10 + 11 + --- 12 + 13 + ## Task 1: Add Grapheme Validation Helper 14 + 15 + **Files:** 16 + - Modify: `apps/appview/src/routes/helpers.ts:56` (add at end) 17 + 18 + **Step 1: Write failing test for grapheme validation** 19 + 20 + Create: `apps/appview/src/routes/__tests__/helpers.test.ts` 21 + 22 + ```typescript 23 + import { describe, it, expect } from "vitest"; 24 + import { validatePostText } from "../helpers.js"; 25 + 26 + describe("validatePostText", () => { 27 + it("accepts text with 300 graphemes", () => { 28 + const text = "a".repeat(300); 29 + const result = validatePostText(text); 30 + expect(result.valid).toBe(true); 31 + expect(result.trimmed).toBe(text); 32 + }); 33 + 34 + it("rejects text with 301 graphemes", () => { 35 + const text = "a".repeat(301); 36 + const result = validatePostText(text); 37 + expect(result.valid).toBe(false); 38 + expect(result.error).toBe("Text must be 300 characters or less"); 39 + }); 40 + 41 + it("rejects empty text after trimming", () => { 42 + const result = validatePostText(" "); 43 + expect(result.valid).toBe(false); 44 + expect(result.error).toBe("Text cannot be empty"); 45 + }); 46 + 47 + it("trims whitespace before validation", () => { 48 + const result = validatePostText(" hello "); 49 + expect(result.valid).toBe(true); 50 + expect(result.trimmed).toBe("hello"); 51 + }); 52 + 53 + it("handles emoji as single graphemes", () => { 54 + // 5 emoji = 5 graphemes (not 10+ code points) 55 + const text = "👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦"; 56 + const result = validatePostText(text); 57 + expect(result.valid).toBe(true); 58 + }); 59 + 60 + it("counts emoji + text correctly", () => { 61 + // Should count correctly: emoji as 1 grapheme each 62 + const text = "👋 Hello world!"; // 1 + 1 (space) + 12 = 14 graphemes 63 + const result = validatePostText(text); 64 + expect(result.valid).toBe(true); 65 + }); 66 + }); 67 + ``` 68 + 69 + **Step 2: Run test to verify it fails** 70 + 71 + Run: 72 + ```bash 73 + cd apps/appview 74 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts 75 + ``` 76 + 77 + Expected: FAIL with "validatePostText is not exported" 78 + 79 + **Step 3: Implement grapheme validation helper** 80 + 81 + Modify: `apps/appview/src/routes/helpers.ts:56` (add at end) 82 + 83 + ```typescript 84 + import { UnicodeString } from "@atproto/api"; 85 + 86 + /** 87 + * Validate post text according to lexicon constraints. 88 + * - Max 300 graphemes (user-perceived characters) 89 + * - Non-empty after trimming whitespace 90 + */ 91 + export function validatePostText(text: string): { 92 + valid: boolean; 93 + trimmed?: string; 94 + error?: string; 95 + } { 96 + const trimmed = text.trim(); 97 + 98 + if (trimmed.length === 0) { 99 + return { valid: false, error: "Text cannot be empty" }; 100 + } 101 + 102 + const graphemeLength = new UnicodeString(trimmed).graphemeLength; 103 + if (graphemeLength > 300) { 104 + return { 105 + valid: false, 106 + error: "Text must be 300 characters or less", 107 + }; 108 + } 109 + 110 + return { valid: true, trimmed }; 111 + } 112 + ``` 113 + 114 + **Step 4: Run test to verify it passes** 115 + 116 + Run: 117 + ```bash 118 + cd apps/appview 119 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts 120 + ``` 121 + 122 + Expected: PASS (6 tests) 123 + 124 + **Step 5: Commit** 125 + 126 + ```bash 127 + git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts 128 + git commit -m "feat(appview): add grapheme validation helper for post text" 129 + ``` 130 + 131 + --- 132 + 133 + ## Task 2: Add Forum Lookup Helper 134 + 135 + **Files:** 136 + - Modify: `apps/appview/src/routes/helpers.ts:82` (add at end) 137 + 138 + **Step 1: Write failing test for forum lookup** 139 + 140 + Modify: `apps/appview/src/routes/__tests__/helpers.test.ts` (add at end) 141 + 142 + ```typescript 143 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 144 + import { getForumByUri } from "../helpers.js"; 145 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 146 + 147 + describe("getForumByUri", () => { 148 + let ctx: TestContext; 149 + 150 + beforeEach(async () => { 151 + ctx = await createTestContext(); 152 + }); 153 + 154 + afterEach(async () => { 155 + await ctx.cleanup(); 156 + }); 157 + 158 + it("returns forum when it exists", async () => { 159 + // Test context creates a forum with rkey='self' 160 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 161 + 162 + const forum = await getForumByUri(ctx.db, forumUri); 163 + 164 + expect(forum).toBeDefined(); 165 + expect(forum?.rkey).toBe("self"); 166 + expect(forum?.did).toBe(ctx.config.forumDid); 167 + }); 168 + 169 + it("returns null when forum does not exist", async () => { 170 + const forumUri = `at://did:plc:nonexistent/space.atbb.forum.forum/self`; 171 + 172 + const forum = await getForumByUri(ctx.db, forumUri); 173 + 174 + expect(forum).toBeNull(); 175 + }); 176 + }); 177 + ``` 178 + 179 + **Step 2: Run test to verify it fails** 180 + 181 + Run: 182 + ```bash 183 + cd apps/appview 184 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts 185 + ``` 186 + 187 + Expected: FAIL with "getForumByUri is not exported" or "createTestContext is not exported" 188 + 189 + **Step 3: Create test context helper (if needed)** 190 + 191 + Create: `apps/appview/src/lib/__tests__/test-context.ts` 192 + 193 + ```typescript 194 + import { drizzle } from "drizzle-orm/postgres-js"; 195 + import postgres from "postgres"; 196 + import * as schema from "@atbb/db"; 197 + import type { AppConfig } from "../config.js"; 198 + 199 + export interface TestContext { 200 + db: ReturnType<typeof drizzle>; 201 + config: AppConfig; 202 + cleanup: () => Promise<void>; 203 + } 204 + 205 + /** 206 + * Create test context with in-memory database and sample data. 207 + * Call cleanup() after tests to close connection. 208 + */ 209 + export async function createTestContext(): Promise<TestContext> { 210 + const config: AppConfig = { 211 + port: 3000, 212 + forumDid: "did:plc:test-forum", 213 + pdsUrl: "https://test.pds", 214 + databaseUrl: process.env.TEST_DATABASE_URL ?? "", 215 + jetstreamUrl: "wss://test.jetstream", 216 + oauthPublicUrl: "http://localhost:3000", 217 + sessionSecret: "test-secret-at-least-32-characters-long", 218 + sessionTtlDays: 7, 219 + }; 220 + 221 + const client = postgres(config.databaseUrl); 222 + const db = drizzle(client, { schema }); 223 + 224 + // Insert test forum 225 + await db.insert(schema.forums).values({ 226 + did: config.forumDid, 227 + rkey: "self", 228 + cid: "bafytest", 229 + name: "Test Forum", 230 + description: "A test forum", 231 + indexedAt: new Date(), 232 + }); 233 + 234 + return { 235 + db, 236 + config, 237 + cleanup: async () => { 238 + await client.end(); 239 + }, 240 + }; 241 + } 242 + ``` 243 + 244 + **Step 4: Implement forum lookup helper** 245 + 246 + Modify: `apps/appview/src/routes/helpers.ts:82` (add at end) 247 + 248 + ```typescript 249 + import { forums } from "@atbb/db"; 250 + import { eq } from "drizzle-orm"; 251 + import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; 252 + 253 + /** 254 + * Look up forum by AT-URI. 255 + * Returns null if forum doesn't exist. 256 + * 257 + * @param db Database instance 258 + * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.forum/self" 259 + */ 260 + export async function getForumByUri( 261 + db: PostgresJsDatabase, 262 + uri: string 263 + ): Promise<{ did: string; rkey: string; cid: string } | null> { 264 + // Parse AT-URI: at://did/collection/rkey 265 + const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/); 266 + if (!match) { 267 + return null; 268 + } 269 + 270 + const [, did, rkey] = match; 271 + 272 + const [forum] = await db 273 + .select({ 274 + did: forums.did, 275 + rkey: forums.rkey, 276 + cid: forums.cid, 277 + }) 278 + .from(forums) 279 + .where(eq(forums.did, did)) 280 + .where(eq(forums.rkey, rkey)) 281 + .limit(1); 282 + 283 + return forum ?? null; 284 + } 285 + ``` 286 + 287 + **Step 5: Run test to verify it passes** 288 + 289 + Run: 290 + ```bash 291 + cd apps/appview 292 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts 293 + ``` 294 + 295 + Expected: PASS (8 tests) 296 + 297 + **Step 6: Commit** 298 + 299 + ```bash 300 + git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts apps/appview/src/lib/__tests__/test-context.ts 301 + git commit -m "feat(appview): add forum lookup helper and test context" 302 + ``` 303 + 304 + --- 305 + 306 + ## Task 3: Implement POST /api/topics Endpoint 307 + 308 + **Files:** 309 + - Modify: `apps/appview/src/routes/topics.ts:92-96` (replace stub) 310 + 311 + **Step 1: Write failing integration test** 312 + 313 + Create: `apps/appview/src/routes/__tests__/topics.test.ts` 314 + 315 + ```typescript 316 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 317 + import { Hono } from "hono"; 318 + import { createTopicsRoutes } from "../topics.js"; 319 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 320 + import { requireAuth } from "../../middleware/auth.js"; 321 + import type { Variables } from "../../types.js"; 322 + 323 + describe("POST /api/topics", () => { 324 + let ctx: TestContext; 325 + let app: Hono<{ Variables: Variables }>; 326 + 327 + beforeEach(async () => { 328 + ctx = await createTestContext(); 329 + 330 + // Mock requireAuth middleware for testing 331 + const mockAuth = vi.fn(async (c, next) => { 332 + // Mock authenticated user with agent 333 + c.set("user", { 334 + did: "did:plc:test-user", 335 + handle: "testuser.test", 336 + pdsUrl: "https://test.pds", 337 + agent: { 338 + com: { 339 + atproto: { 340 + repo: { 341 + putRecord: vi.fn(async () => ({ 342 + uri: "at://did:plc:test-user/space.atbb.post/3lbk7test", 343 + cid: "bafytest", 344 + })), 345 + }, 346 + }, 347 + }, 348 + }, 349 + }); 350 + await next(); 351 + }); 352 + 353 + app = new Hono<{ Variables: Variables }>(); 354 + app.route("/api/topics", createTopicsRoutes(ctx).use("/*", mockAuth)); 355 + }); 356 + 357 + afterEach(async () => { 358 + await ctx.cleanup(); 359 + }); 360 + 361 + it("creates topic with valid text", async () => { 362 + const res = await app.request("/api/topics", { 363 + method: "POST", 364 + headers: { "Content-Type": "application/json" }, 365 + body: JSON.stringify({ text: "Hello, atBB!" }), 366 + }); 367 + 368 + expect(res.status).toBe(201); 369 + const data = await res.json(); 370 + expect(data.uri).toMatch(/^at:\/\/did:plc:test-user\/space\.atbb\.post\/3/); 371 + expect(data.cid).toBeTruthy(); 372 + expect(data.rkey).toBeTruthy(); 373 + }); 374 + 375 + it("returns 400 for empty text", async () => { 376 + const res = await app.request("/api/topics", { 377 + method: "POST", 378 + headers: { "Content-Type": "application/json" }, 379 + body: JSON.stringify({ text: " " }), 380 + }); 381 + 382 + expect(res.status).toBe(400); 383 + const data = await res.json(); 384 + expect(data.error).toContain("empty"); 385 + }); 386 + 387 + it("returns 400 for text exceeding 300 graphemes", async () => { 388 + const res = await app.request("/api/topics", { 389 + method: "POST", 390 + headers: { "Content-Type": "application/json" }, 391 + body: JSON.stringify({ text: "a".repeat(301) }), 392 + }); 393 + 394 + expect(res.status).toBe(400); 395 + const data = await res.json(); 396 + expect(data.error).toContain("300 characters"); 397 + }); 398 + 399 + it("uses default forum URI when not provided", async () => { 400 + const res = await app.request("/api/topics", { 401 + method: "POST", 402 + headers: { "Content-Type": "application/json" }, 403 + body: JSON.stringify({ text: "Test topic" }), 404 + }); 405 + 406 + expect(res.status).toBe(201); 407 + // Verify putRecord was called with correct forum ref 408 + const user = app.get("user"); 409 + const putRecord = user.agent.com.atproto.repo.putRecord; 410 + expect(putRecord).toHaveBeenCalledWith( 411 + expect.objectContaining({ 412 + record: expect.objectContaining({ 413 + forum: expect.objectContaining({ 414 + forum: expect.objectContaining({ 415 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 416 + }), 417 + }), 418 + }), 419 + }) 420 + ); 421 + }); 422 + 423 + it("returns 404 when custom forum does not exist", async () => { 424 + const res = await app.request("/api/topics", { 425 + method: "POST", 426 + headers: { "Content-Type": "application/json" }, 427 + body: JSON.stringify({ 428 + text: "Test", 429 + forumUri: "at://did:plc:nonexistent/space.atbb.forum.forum/self", 430 + }), 431 + }); 432 + 433 + expect(res.status).toBe(404); 434 + const data = await res.json(); 435 + expect(data.error).toContain("Forum not found"); 436 + }); 437 + }); 438 + ``` 439 + 440 + **Step 2: Run test to verify it fails** 441 + 442 + Run: 443 + ```bash 444 + cd apps/appview 445 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/topics.test.ts 446 + ``` 447 + 448 + Expected: FAIL with "not implemented" response 449 + 450 + **Step 3: Implement POST /api/topics endpoint** 451 + 452 + Modify: `apps/appview/src/routes/topics.ts:92-96` 453 + 454 + Replace the stub: 455 + ```typescript 456 + .post("/", (c) => { 457 + // Phase 2: create space.atbb.post record with forumRef but no reply ref 458 + // This requires authentication and PDS write operations 459 + return c.json({ error: "not implemented" }, 501); 460 + }); 461 + ``` 462 + 463 + With the implementation: 464 + ```typescript 465 + .post("/", requireAuth(ctx), async (c) => { 466 + const user = c.get("user"); 467 + 468 + // Parse and validate request body 469 + const body = await c.req.json(); 470 + const { text, forumUri: customForumUri } = body; 471 + 472 + // Validate text 473 + const validation = validatePostText(text); 474 + if (!validation.valid) { 475 + return c.json({ error: validation.error }, 400); 476 + } 477 + 478 + try { 479 + // Resolve forum URI (default to singleton forum) 480 + const forumUri = 481 + customForumUri ?? 482 + `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 483 + 484 + // Look up forum to get CID 485 + const forum = await getForumByUri(ctx.db, forumUri); 486 + if (!forum) { 487 + return c.json({ error: "Forum not found" }, 404); 488 + } 489 + 490 + // Generate TID for rkey 491 + const rkey = TID.nextStr(); 492 + 493 + // Write to user's PDS 494 + const result = await user.agent.com.atproto.repo.putRecord({ 495 + repo: user.did, 496 + collection: "space.atbb.post", 497 + rkey, 498 + record: { 499 + $type: "space.atbb.post", 500 + text: validation.trimmed!, 501 + forum: { 502 + forum: { uri: forumUri, cid: forum.cid }, 503 + }, 504 + createdAt: new Date().toISOString(), 505 + }, 506 + }); 507 + 508 + return c.json( 509 + { 510 + uri: result.uri, 511 + cid: result.cid, 512 + rkey, 513 + }, 514 + 201 515 + ); 516 + } catch (error) { 517 + console.error("Failed to create topic", { 518 + operation: "POST /api/topics", 519 + userId: user.did, 520 + error: error instanceof Error ? error.message : String(error), 521 + }); 522 + 523 + // Distinguish PDS errors from unexpected errors 524 + if (error instanceof Error && error.message.includes("fetch failed")) { 525 + return c.json( 526 + { 527 + error: "Unable to reach your PDS. Please try again later.", 528 + }, 529 + 503 530 + ); 531 + } 532 + 533 + return c.json( 534 + { 535 + error: "Failed to create topic. Please try again later.", 536 + }, 537 + 500 538 + ); 539 + } 540 + }); 541 + ``` 542 + 543 + Add imports at top of file: 544 + ```typescript 545 + import { TID } from "@atproto/common-web"; 546 + import { requireAuth } from "../middleware/auth.js"; 547 + import { validatePostText, getForumByUri } from "./helpers.js"; 548 + ``` 549 + 550 + **Step 4: Run test to verify it passes** 551 + 552 + Run: 553 + ```bash 554 + cd apps/appview 555 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/topics.test.ts 556 + ``` 557 + 558 + Expected: PASS (5 tests) 559 + 560 + **Step 5: Commit** 561 + 562 + ```bash 563 + git add apps/appview/src/routes/topics.ts apps/appview/src/routes/__tests__/topics.test.ts 564 + git commit -m "feat(appview): implement POST /api/topics endpoint" 565 + ``` 566 + 567 + --- 568 + 569 + ## Task 4: Add Post Lookup and Validation Helpers 570 + 571 + **Files:** 572 + - Modify: `apps/appview/src/routes/helpers.ts:125` (add at end) 573 + 574 + **Step 1: Write failing test for post lookup** 575 + 576 + Modify: `apps/appview/src/routes/__tests__/helpers.test.ts` (add at end) 577 + 578 + ```typescript 579 + import { getPostsByIds, validateReplyParent } from "../helpers.js"; 580 + import { posts, users } from "@atbb/db"; 581 + 582 + describe("getPostsByIds", () => { 583 + let ctx: TestContext; 584 + 585 + beforeEach(async () => { 586 + ctx = await createTestContext(); 587 + 588 + // Insert test user 589 + await ctx.db.insert(users).values({ 590 + did: "did:plc:test-user", 591 + handle: "testuser.test", 592 + indexedAt: new Date(), 593 + }); 594 + 595 + // Insert test posts 596 + await ctx.db.insert(posts).values([ 597 + { 598 + did: "did:plc:test-user", 599 + rkey: "3lbk7topic", 600 + cid: "bafytopic", 601 + text: "Topic post", 602 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 603 + createdAt: new Date(), 604 + indexedAt: new Date(), 605 + deleted: false, 606 + }, 607 + { 608 + did: "did:plc:test-user", 609 + rkey: "3lbk8reply", 610 + cid: "bafyreply", 611 + text: "Reply post", 612 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 613 + rootPostId: 1n, // Assuming topic has id=1 614 + parentPostId: 1n, 615 + rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 616 + parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 617 + createdAt: new Date(), 618 + indexedAt: new Date(), 619 + deleted: false, 620 + }, 621 + ]); 622 + }); 623 + 624 + afterEach(async () => { 625 + await ctx.cleanup(); 626 + }); 627 + 628 + it("returns posts when they exist", async () => { 629 + const result = await getPostsByIds(ctx.db, [1n, 2n]); 630 + 631 + expect(result.size).toBe(2); 632 + expect(result.get(1n)?.rkey).toBe("3lbk7topic"); 633 + expect(result.get(2n)?.rkey).toBe("3lbk8reply"); 634 + }); 635 + 636 + it("excludes deleted posts", async () => { 637 + // Mark topic as deleted 638 + await ctx.db 639 + .update(posts) 640 + .set({ deleted: true }) 641 + .where(eq(posts.id, 1n)); 642 + 643 + const result = await getPostsByIds(ctx.db, [1n, 2n]); 644 + 645 + expect(result.size).toBe(1); 646 + expect(result.has(1n)).toBe(false); 647 + expect(result.has(2n)).toBe(true); 648 + }); 649 + 650 + it("returns empty map for non-existent IDs", async () => { 651 + const result = await getPostsByIds(ctx.db, [999n]); 652 + 653 + expect(result.size).toBe(0); 654 + }); 655 + }); 656 + 657 + describe("validateReplyParent", () => { 658 + it("accepts when parent IS the root", () => { 659 + const root = { id: 1n, rootPostId: null }; 660 + const parent = { id: 1n, rootPostId: null }; 661 + 662 + const result = validateReplyParent(root, parent, 1n); 663 + 664 + expect(result.valid).toBe(true); 665 + }); 666 + 667 + it("accepts when parent is a reply in same thread", () => { 668 + const root = { id: 1n, rootPostId: null }; 669 + const parent = { id: 2n, rootPostId: 1n }; 670 + 671 + const result = validateReplyParent(root, parent, 1n); 672 + 673 + expect(result.valid).toBe(true); 674 + }); 675 + 676 + it("rejects when parent belongs to different thread", () => { 677 + const root = { id: 1n, rootPostId: null }; 678 + const parent = { id: 2n, rootPostId: 99n }; // Different root 679 + 680 + const result = validateReplyParent(root, parent, 1n); 681 + 682 + expect(result.valid).toBe(false); 683 + expect(result.error).toContain("different thread"); 684 + }); 685 + 686 + it("rejects when parent is a root but not THE root", () => { 687 + const root = { id: 1n, rootPostId: null }; 688 + const parent = { id: 2n, rootPostId: null }; // Also a root, but different 689 + 690 + const result = validateReplyParent(root, parent, 1n); 691 + 692 + expect(result.valid).toBe(false); 693 + expect(result.error).toContain("different thread"); 694 + }); 695 + }); 696 + ``` 697 + 698 + **Step 2: Run test to verify it fails** 699 + 700 + Run: 701 + ```bash 702 + cd apps/appview 703 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts 704 + ``` 705 + 706 + Expected: FAIL with "getPostsByIds is not exported" 707 + 708 + **Step 3: Implement post lookup and validation helpers** 709 + 710 + Modify: `apps/appview/src/routes/helpers.ts:125` (add at end) 711 + 712 + ```typescript 713 + import { posts } from "@atbb/db"; 714 + import { inArray, and, eq } from "drizzle-orm"; 715 + 716 + export type PostRow = typeof posts.$inferSelect; 717 + 718 + /** 719 + * Look up multiple posts by ID in a single query. 720 + * Excludes deleted posts. 721 + * Returns a Map for O(1) lookup. 722 + */ 723 + export async function getPostsByIds( 724 + db: PostgresJsDatabase, 725 + ids: bigint[] 726 + ): Promise<Map<bigint, PostRow>> { 727 + if (ids.length === 0) { 728 + return new Map(); 729 + } 730 + 731 + const results = await db 732 + .select() 733 + .from(posts) 734 + .where(and(inArray(posts.id, ids), eq(posts.deleted, false))); 735 + 736 + return new Map(results.map((post) => [post.id, post])); 737 + } 738 + 739 + /** 740 + * Validate that a parent post belongs to the same thread as the root. 741 + * 742 + * Rules: 743 + * - Parent can BE the root (replying directly to topic) 744 + * - Parent can be a reply in the same thread (parent.rootPostId === rootId) 745 + * - Parent cannot belong to a different thread 746 + */ 747 + export function validateReplyParent( 748 + root: { id: bigint; rootPostId: bigint | null }, 749 + parent: { id: bigint; rootPostId: bigint | null }, 750 + rootId: bigint 751 + ): { valid: boolean; error?: string } { 752 + // Parent IS the root (replying to topic) 753 + if (parent.id === rootId && parent.rootPostId === null) { 754 + return { valid: true }; 755 + } 756 + 757 + // Parent is a reply in the same thread 758 + if (parent.rootPostId === rootId) { 759 + return { valid: true }; 760 + } 761 + 762 + // Parent belongs to a different thread 763 + return { 764 + valid: false, 765 + error: "Parent post does not belong to this thread", 766 + }; 767 + } 768 + ``` 769 + 770 + **Step 4: Run test to verify it passes** 771 + 772 + Run: 773 + ```bash 774 + cd apps/appview 775 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/helpers.test.ts 776 + ``` 777 + 778 + Expected: PASS (14 tests) 779 + 780 + **Step 5: Commit** 781 + 782 + ```bash 783 + git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts 784 + git commit -m "feat(appview): add post lookup and reply validation helpers" 785 + ``` 786 + 787 + --- 788 + 789 + ## Task 5: Implement POST /api/posts Endpoint 790 + 791 + **Files:** 792 + - Modify: `apps/appview/src/routes/posts.ts:1-6` (replace entire file) 793 + 794 + **Step 1: Write failing integration test** 795 + 796 + Create: `apps/appview/src/routes/__tests__/posts.test.ts` 797 + 798 + ```typescript 799 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 800 + import { Hono } from "hono"; 801 + import { createPostsRoutes } from "../posts.js"; 802 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 803 + import type { Variables } from "../../types.js"; 804 + import { posts, users } from "@atbb/db"; 805 + 806 + describe("POST /api/posts", () => { 807 + let ctx: TestContext; 808 + let app: Hono<{ Variables: Variables }>; 809 + 810 + beforeEach(async () => { 811 + ctx = await createTestContext(); 812 + 813 + // Insert test user 814 + await ctx.db.insert(users).values({ 815 + did: "did:plc:test-user", 816 + handle: "testuser.test", 817 + indexedAt: new Date(), 818 + }); 819 + 820 + // Insert topic (root post) 821 + await ctx.db.insert(posts).values({ 822 + did: "did:plc:test-user", 823 + rkey: "3lbk7topic", 824 + cid: "bafytopic", 825 + text: "Topic post", 826 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 827 + createdAt: new Date(), 828 + indexedAt: new Date(), 829 + deleted: false, 830 + }); 831 + 832 + // Insert reply 833 + await ctx.db.insert(posts).values({ 834 + did: "did:plc:test-user", 835 + rkey: "3lbk8reply", 836 + cid: "bafyreply", 837 + text: "Reply post", 838 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 839 + rootPostId: 1n, 840 + parentPostId: 1n, 841 + rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 842 + parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 843 + createdAt: new Date(), 844 + indexedAt: new Date(), 845 + deleted: false, 846 + }); 847 + 848 + // Mock auth middleware 849 + const mockAuth = vi.fn(async (c, next) => { 850 + c.set("user", { 851 + did: "did:plc:test-user", 852 + handle: "testuser.test", 853 + pdsUrl: "https://test.pds", 854 + agent: { 855 + com: { 856 + atproto: { 857 + repo: { 858 + putRecord: vi.fn(async () => ({ 859 + uri: "at://did:plc:test-user/space.atbb.post/3lbk9test", 860 + cid: "bafytest", 861 + })), 862 + }, 863 + }, 864 + }, 865 + }, 866 + }); 867 + await next(); 868 + }); 869 + 870 + app = new Hono<{ Variables: Variables }>(); 871 + app.route("/api/posts", createPostsRoutes(ctx).use("/*", mockAuth)); 872 + }); 873 + 874 + afterEach(async () => { 875 + await ctx.cleanup(); 876 + }); 877 + 878 + it("creates reply to topic", async () => { 879 + const res = await app.request("/api/posts", { 880 + method: "POST", 881 + headers: { "Content-Type": "application/json" }, 882 + body: JSON.stringify({ 883 + text: "My reply", 884 + rootPostId: "1", 885 + parentPostId: "1", 886 + }), 887 + }); 888 + 889 + expect(res.status).toBe(201); 890 + const data = await res.json(); 891 + expect(data.uri).toBeTruthy(); 892 + expect(data.cid).toBeTruthy(); 893 + expect(data.rkey).toBeTruthy(); 894 + }); 895 + 896 + it("creates reply to reply", async () => { 897 + const res = await app.request("/api/posts", { 898 + method: "POST", 899 + headers: { "Content-Type": "application/json" }, 900 + body: JSON.stringify({ 901 + text: "Nested reply", 902 + rootPostId: "1", 903 + parentPostId: "2", // Reply to the reply 904 + }), 905 + }); 906 + 907 + expect(res.status).toBe(201); 908 + }); 909 + 910 + it("returns 400 for invalid parent ID format", async () => { 911 + const res = await app.request("/api/posts", { 912 + method: "POST", 913 + headers: { "Content-Type": "application/json" }, 914 + body: JSON.stringify({ 915 + text: "Test", 916 + rootPostId: "not-a-number", 917 + parentPostId: "1", 918 + }), 919 + }); 920 + 921 + expect(res.status).toBe(400); 922 + const data = await res.json(); 923 + expect(data.error).toContain("Invalid"); 924 + }); 925 + 926 + it("returns 404 when root post does not exist", async () => { 927 + const res = await app.request("/api/posts", { 928 + method: "POST", 929 + headers: { "Content-Type": "application/json" }, 930 + body: JSON.stringify({ 931 + text: "Test", 932 + rootPostId: "999", 933 + parentPostId: "999", 934 + }), 935 + }); 936 + 937 + expect(res.status).toBe(404); 938 + const data = await res.json(); 939 + expect(data.error).toContain("not found"); 940 + }); 941 + 942 + it("returns 404 when parent post does not exist", async () => { 943 + const res = await app.request("/api/posts", { 944 + method: "POST", 945 + headers: { "Content-Type": "application/json" }, 946 + body: JSON.stringify({ 947 + text: "Test", 948 + rootPostId: "1", 949 + parentPostId: "999", 950 + }), 951 + }); 952 + 953 + expect(res.status).toBe(404); 954 + const data = await res.json(); 955 + expect(data.error).toContain("not found"); 956 + }); 957 + 958 + it("returns 400 when parent belongs to different thread", async () => { 959 + // Insert a different topic 960 + await ctx.db.insert(posts).values({ 961 + did: "did:plc:test-user", 962 + rkey: "3lbkaother", 963 + cid: "bafyother", 964 + text: "Other topic", 965 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 966 + createdAt: new Date(), 967 + indexedAt: new Date(), 968 + deleted: false, 969 + }); 970 + 971 + const res = await app.request("/api/posts", { 972 + method: "POST", 973 + headers: { "Content-Type": "application/json" }, 974 + body: JSON.stringify({ 975 + text: "Test", 976 + rootPostId: "1", 977 + parentPostId: "3", // Different thread 978 + }), 979 + }); 980 + 981 + expect(res.status).toBe(400); 982 + const data = await res.json(); 983 + expect(data.error).toContain("thread"); 984 + }); 985 + }); 986 + ``` 987 + 988 + **Step 2: Run test to verify it fails** 989 + 990 + Run: 991 + ```bash 992 + cd apps/appview 993 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/posts.test.ts 994 + ``` 995 + 996 + Expected: FAIL with "not implemented" 997 + 998 + **Step 3: Implement POST /api/posts endpoint** 999 + 1000 + Modify: `apps/appview/src/routes/posts.ts` (replace entire file) 1001 + 1002 + ```typescript 1003 + import { Hono } from "hono"; 1004 + import { TID } from "@atproto/common-web"; 1005 + import type { AppContext } from "../lib/app-context.js"; 1006 + import { requireAuth } from "../middleware/auth.js"; 1007 + import { 1008 + validatePostText, 1009 + parseBigIntParam, 1010 + getPostsByIds, 1011 + validateReplyParent, 1012 + } from "./helpers.js"; 1013 + 1014 + export function createPostsRoutes(ctx: AppContext) { 1015 + return new Hono().post("/", requireAuth(ctx), async (c) => { 1016 + const user = c.get("user"); 1017 + 1018 + // Parse and validate request body 1019 + const body = await c.req.json(); 1020 + const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body; 1021 + 1022 + // Validate text 1023 + const validation = validatePostText(text); 1024 + if (!validation.valid) { 1025 + return c.json({ error: validation.error }, 400); 1026 + } 1027 + 1028 + // Parse IDs 1029 + const rootId = parseBigIntParam(rootIdStr); 1030 + const parentId = parseBigIntParam(parentIdStr); 1031 + 1032 + if (rootId === null || parentId === null) { 1033 + return c.json( 1034 + { 1035 + error: "Invalid post ID format. IDs must be numeric strings.", 1036 + }, 1037 + 400 1038 + ); 1039 + } 1040 + 1041 + try { 1042 + // Look up root and parent posts 1043 + const postsMap = await getPostsByIds(ctx.db, [rootId, parentId]); 1044 + 1045 + const root = postsMap.get(rootId); 1046 + const parent = postsMap.get(parentId); 1047 + 1048 + if (!root) { 1049 + return c.json({ error: "Root post not found" }, 404); 1050 + } 1051 + 1052 + if (!parent) { 1053 + return c.json({ error: "Parent post not found" }, 404); 1054 + } 1055 + 1056 + // Validate parent belongs to same thread 1057 + const parentValidation = validateReplyParent(root, parent, rootId); 1058 + if (!parentValidation.valid) { 1059 + return c.json({ error: parentValidation.error }, 400); 1060 + } 1061 + 1062 + // Construct AT-URIs 1063 + const rootUri = `at://${root.did}/space.atbb.post/${root.rkey}`; 1064 + const parentUri = `at://${parent.did}/space.atbb.post/${parent.rkey}`; 1065 + 1066 + // Generate TID for rkey 1067 + const rkey = TID.nextStr(); 1068 + 1069 + // Write to user's PDS 1070 + const result = await user.agent.com.atproto.repo.putRecord({ 1071 + repo: user.did, 1072 + collection: "space.atbb.post", 1073 + rkey, 1074 + record: { 1075 + $type: "space.atbb.post", 1076 + text: validation.trimmed!, 1077 + forum: { 1078 + forum: { uri: root.forumUri!, cid: root.cid }, 1079 + }, 1080 + reply: { 1081 + root: { uri: rootUri, cid: root.cid }, 1082 + parent: { uri: parentUri, cid: parent.cid }, 1083 + }, 1084 + createdAt: new Date().toISOString(), 1085 + }, 1086 + }); 1087 + 1088 + return c.json( 1089 + { 1090 + uri: result.uri, 1091 + cid: result.cid, 1092 + rkey, 1093 + }, 1094 + 201 1095 + ); 1096 + } catch (error) { 1097 + console.error("Failed to create post", { 1098 + operation: "POST /api/posts", 1099 + userId: user.did, 1100 + rootId: rootIdStr, 1101 + parentId: parentIdStr, 1102 + error: error instanceof Error ? error.message : String(error), 1103 + }); 1104 + 1105 + // Distinguish PDS errors from unexpected errors 1106 + if (error instanceof Error && error.message.includes("fetch failed")) { 1107 + return c.json( 1108 + { 1109 + error: "Unable to reach your PDS. Please try again later.", 1110 + }, 1111 + 503 1112 + ); 1113 + } 1114 + 1115 + return c.json( 1116 + { 1117 + error: "Failed to create post. Please try again later.", 1118 + }, 1119 + 500 1120 + ); 1121 + } 1122 + }); 1123 + } 1124 + ``` 1125 + 1126 + **Step 4: Update posts route registration** 1127 + 1128 + Modify: `apps/appview/src/index.ts` (find where `postsRoutes` is registered) 1129 + 1130 + Replace: 1131 + ```typescript 1132 + import { postsRoutes } from "./routes/posts.js"; 1133 + app.route("/api/posts", postsRoutes); 1134 + ``` 1135 + 1136 + With: 1137 + ```typescript 1138 + import { createPostsRoutes } from "./routes/posts.js"; 1139 + app.route("/api/posts", createPostsRoutes(appContext)); 1140 + ``` 1141 + 1142 + **Step 5: Run test to verify it passes** 1143 + 1144 + Run: 1145 + ```bash 1146 + cd apps/appview 1147 + PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" pnpm test src/routes/__tests__/posts.test.ts 1148 + ``` 1149 + 1150 + Expected: PASS (6 tests) 1151 + 1152 + **Step 6: Commit** 1153 + 1154 + ```bash 1155 + git add apps/appview/src/routes/posts.ts apps/appview/src/routes/__tests__/posts.test.ts apps/appview/src/index.ts 1156 + git commit -m "feat(appview): implement POST /api/posts endpoint for replies" 1157 + ``` 1158 + 1159 + --- 1160 + 1161 + ## Task 6: Update Documentation and Linear 1162 + 1163 + **Files:** 1164 + - Modify: `docs/atproto-forum-plan.md` (mark ATB-12 complete) 1165 + 1166 + **Step 1: Mark ATB-12 complete in plan document** 1167 + 1168 + Modify: `docs/atproto-forum-plan.md` 1169 + 1170 + Find the ATB-12 checkbox and update it: 1171 + ```markdown 1172 + - [x] **ATB-12:** Implement write-path API endpoints (POST /api/topics, POST /api/posts) ✅ **DONE** - Thin proxy endpoints with OAuth auth, grapheme validation, reply thread validation. Tests passing. (See `apps/appview/src/routes/topics.ts:92-145`, `apps/appview/src/routes/posts.ts`) 1173 + ``` 1174 + 1175 + **Step 2: Commit documentation update** 1176 + 1177 + ```bash 1178 + git add docs/atproto-forum-plan.md 1179 + git commit -m "docs: mark ATB-12 complete in project plan" 1180 + ``` 1181 + 1182 + **Step 3: Update Linear issue** 1183 + 1184 + Update ATB-12 status to Done and add completion comment with implementation summary and file references. 1185 + 1186 + --- 1187 + 1188 + ## Summary 1189 + 1190 + **Total Tasks:** 6 1191 + **Estimated Time:** 2-3 hours 1192 + **Test Coverage:** Unit tests for helpers, integration tests for endpoints 1193 + **Dependencies:** @atproto/api (UnicodeString), @atproto/common-web (TID), requireAuth middleware from ATB-14 1194 + 1195 + **Key Implementation Points:** 1196 + - Grapheme validation ensures proper Unicode handling (emoji count correctly) 1197 + - Fire-and-forget design: return immediately after PDS write 1198 + - No optimistic DB writes (indexer handles that asynchronously) 1199 + - Comprehensive error handling with structured logging 1200 + - Thread validation prevents replies to wrong topics 1201 + 1202 + **Testing Strategy:** 1203 + - Mock `user.agent.com.atproto.repo.putRecord()` for fast tests 1204 + - Use real database with test fixtures for integration tests 1205 + - Verify error cases: empty text, text too long, invalid IDs, missing posts, wrong thread