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

feat(appview): implement write-path API endpoints (ATB-12)

Implements POST /api/topics and POST /api/posts for creating forum posts via OAuth-authenticated PDS writes. Includes comprehensive error handling, Unicode validation, and fire-and-forget design with firehose indexing.

- 29 new tests, 134 total tests passing
- All critical review issues resolved across 3 review rounds
- Phase 1 (AppView Core) now 100% complete

Closes ATB-12

authored by

Malpercio and committed by
GitHub
79554f45 b7bc6376

+1715 -94
+2 -1
apps/appview/package.json
··· 22 "@hono/node-server": "^1.14.0", 23 "@skyware/jetstream": "^0.2.5", 24 "drizzle-orm": "^0.45.1", 25 - "hono": "^4.7.0" 26 }, 27 "devDependencies": { 28 "@types/node": "^22.0.0",
··· 22 "@hono/node-server": "^1.14.0", 23 "@skyware/jetstream": "^0.2.5", 24 "drizzle-orm": "^0.45.1", 25 + "hono": "^4.7.0", 26 + "postgres": "^3.4.8" 27 }, 28 "devDependencies": { 29 "@types/node": "^22.0.0",
+53 -44
apps/appview/src/lib/__tests__/test-context.ts
··· 1 - import { createDb } from "@atbb/db"; 2 - import { FirehoseService } from "../firehose.js"; 3 - import { NodeOAuthClient } from "@atproto/oauth-client-node"; 4 - import { OAuthStateStore, OAuthSessionStore } from "../oauth-stores.js"; 5 - import { CookieSessionStore } from "../cookie-session-store.js"; 6 import type { AppContext } from "../app-context.js"; 7 - import type { AppConfig } from "../config.js"; 8 9 /** 10 - * Create a test application context with in-memory database. 11 - * Useful for integration tests. 12 */ 13 - export function createTestContext(overrides?: Partial<AppConfig>): AppContext { 14 const config: AppConfig = { 15 port: 3000, 16 - forumDid: "did:plc:test", 17 pdsUrl: "https://test.pds", 18 databaseUrl: process.env.DATABASE_URL ?? "", 19 jetstreamUrl: "wss://test.jetstream", 20 - // OAuth configuration (test defaults) 21 oauthPublicUrl: "http://localhost:3000", 22 - sessionSecret: "test-secret-key-32-chars-minimum!!", 23 sessionTtlDays: 7, 24 - redisUrl: undefined, 25 - ...overrides, 26 }; 27 28 - const db = createDb(config.databaseUrl); 29 - const firehose = new FirehoseService(db, config.jetstreamUrl); 30 31 - // Initialize OAuth stores 32 - const oauthStateStore = new OAuthStateStore(); 33 - const oauthSessionStore = new OAuthSessionStore(); 34 - const cookieSessionStore = new CookieSessionStore(); 35 36 - // Initialize OAuth client with test configuration 37 - const oauthClient = new NodeOAuthClient({ 38 - clientMetadata: { 39 - client_id: `${config.oauthPublicUrl}/.well-known/oauth-client-metadata`, 40 - client_name: "atBB Forum (Test)", 41 - client_uri: config.oauthPublicUrl, 42 - redirect_uris: [`${config.oauthPublicUrl}/api/auth/callback`], 43 - scope: "atproto", 44 - grant_types: ["authorization_code", "refresh_token"], 45 - response_types: ["code"], 46 - application_type: "web", 47 - token_endpoint_auth_method: "none", 48 - dpop_bound_access_tokens: true, 49 - }, 50 - stateStore: oauthStateStore, 51 - sessionStore: oauthSessionStore, 52 - }); 53 54 return { 55 - config, 56 db, 57 - firehose, 58 - oauthClient, 59 - oauthStateStore, 60 - oauthSessionStore, 61 - cookieSessionStore, 62 - }; 63 }
··· 1 + import { eq } from "drizzle-orm"; 2 + import { drizzle } from "drizzle-orm/postgres-js"; 3 + import postgres from "postgres"; 4 + import { forums, posts, users } from "@atbb/db"; 5 + import * as schema from "@atbb/db"; 6 + import type { AppConfig } from "../config.js"; 7 import type { AppContext } from "../app-context.js"; 8 + 9 + export interface TestContext extends AppContext { 10 + cleanup: () => Promise<void>; 11 + } 12 13 /** 14 + * Create test context with database and sample data. 15 + * Call cleanup() after tests to remove test data. 16 */ 17 + export async function createTestContext(): Promise<AppContext> { 18 const config: AppConfig = { 19 port: 3000, 20 + forumDid: "did:plc:test-forum", 21 pdsUrl: "https://test.pds", 22 databaseUrl: process.env.DATABASE_URL ?? "", 23 jetstreamUrl: "wss://test.jetstream", 24 oauthPublicUrl: "http://localhost:3000", 25 + sessionSecret: "test-secret-at-least-32-characters-long", 26 sessionTtlDays: 7, 27 }; 28 29 + // Create postgres client so we can close it later 30 + const sql = postgres(config.databaseUrl); 31 + const db = drizzle(sql, { schema }); 32 33 + // Insert test forum 34 + await db.insert(forums).values({ 35 + did: config.forumDid, 36 + rkey: "self", 37 + cid: "bafytest", 38 + name: "Test Forum", 39 + description: "A test forum", 40 + indexedAt: new Date(), 41 + }); 42 + 43 + // Create stub OAuth dependencies (unused in read-path tests) 44 + const stubFirehose = { 45 + start: () => Promise.resolve(), 46 + stop: () => Promise.resolve(), 47 + } as any; 48 49 + const stubOAuthClient = {} as any; 50 + const stubOAuthStateStore = { destroy: () => {} } as any; 51 + const stubOAuthSessionStore = { destroy: () => {} } as any; 52 + const stubCookieSessionStore = { destroy: () => {} } as any; 53 54 return { 55 db, 56 + config, 57 + firehose: stubFirehose, 58 + oauthClient: stubOAuthClient, 59 + oauthStateStore: stubOAuthStateStore, 60 + oauthSessionStore: stubOAuthSessionStore, 61 + cookieSessionStore: stubCookieSessionStore, 62 + cleanup: async () => { 63 + // Clean up test data (order matters due to FKs: posts -> users -> forums) 64 + // Only delete posts/users created by test-specific DIDs 65 + await db.delete(posts).where(eq(posts.did, "did:plc:test-user")); 66 + await db.delete(users).where(eq(users.did, "did:plc:test-user")); 67 + await db.delete(forums).where(eq(forums.did, config.forumDid)); 68 + // Close postgres connection to prevent leaks 69 + await sql.end(); 70 + }, 71 + } as AppContext & { cleanup: () => Promise<void> }; 72 }
+241
apps/appview/src/routes/__tests__/helpers.test.ts
···
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { validatePostText, getForumByUri, getPostsByIds, validateReplyParent } from "../helpers.js"; 3 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 + import { posts, users } from "@atbb/db"; 5 + import { eq } from "drizzle-orm"; 6 + 7 + describe("validatePostText", () => { 8 + it("accepts text with 300 graphemes", () => { 9 + const text = "a".repeat(300); 10 + const result = validatePostText(text); 11 + expect(result.valid).toBe(true); 12 + expect(result.trimmed).toBe(text); 13 + }); 14 + 15 + it("rejects text with 301 graphemes", () => { 16 + const text = "a".repeat(301); 17 + const result = validatePostText(text); 18 + expect(result.valid).toBe(false); 19 + expect(result.error).toBe("Text must be 300 characters or less"); 20 + }); 21 + 22 + it("rejects empty text after trimming", () => { 23 + const result = validatePostText(" "); 24 + expect(result.valid).toBe(false); 25 + expect(result.error).toBe("Text cannot be empty"); 26 + }); 27 + 28 + it("trims whitespace before validation", () => { 29 + const result = validatePostText(" hello "); 30 + expect(result.valid).toBe(true); 31 + expect(result.trimmed).toBe("hello"); 32 + }); 33 + 34 + it("handles emoji as single graphemes", () => { 35 + // 5 emoji = 5 graphemes (not 10+ code points) 36 + const text = "👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦"; 37 + const result = validatePostText(text); 38 + expect(result.valid).toBe(true); 39 + }); 40 + 41 + it("counts emoji + text correctly", () => { 42 + // Should count correctly: emoji as 1 grapheme each 43 + const text = "👋 Hello world!"; // 1 + 1 (space) + 12 = 14 graphemes 44 + const result = validatePostText(text); 45 + expect(result.valid).toBe(true); 46 + }); 47 + 48 + // Critical Issue #1: Test type guard 49 + it("rejects non-string input (number)", () => { 50 + const result = validatePostText(123 as any); 51 + expect(result.valid).toBe(false); 52 + expect(result.error).toBe("Text is required and must be a string"); 53 + }); 54 + 55 + it("rejects non-string input (null)", () => { 56 + const result = validatePostText(null as any); 57 + expect(result.valid).toBe(false); 58 + expect(result.error).toBe("Text is required and must be a string"); 59 + }); 60 + 61 + it("rejects non-string input (undefined)", () => { 62 + const result = validatePostText(undefined as any); 63 + expect(result.valid).toBe(false); 64 + expect(result.error).toBe("Text is required and must be a string"); 65 + }); 66 + 67 + it("rejects non-string input (object)", () => { 68 + const result = validatePostText({ text: "hello" } as any); 69 + expect(result.valid).toBe(false); 70 + expect(result.error).toBe("Text is required and must be a string"); 71 + }); 72 + 73 + it("rejects non-string input (array)", () => { 74 + const result = validatePostText(["hello"] as any); 75 + expect(result.valid).toBe(false); 76 + expect(result.error).toBe("Text is required and must be a string"); 77 + }); 78 + }); 79 + 80 + describe("getForumByUri", () => { 81 + let ctx: TestContext; 82 + 83 + beforeEach(async () => { 84 + ctx = await createTestContext(); 85 + }); 86 + 87 + afterEach(async () => { 88 + await ctx.cleanup(); 89 + }); 90 + 91 + it("returns forum when it exists", async () => { 92 + // Test context creates a forum with rkey='self' 93 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 94 + 95 + const forum = await getForumByUri(ctx.db, forumUri); 96 + 97 + expect(forum).toBeDefined(); 98 + expect(forum?.rkey).toBe("self"); 99 + expect(forum?.did).toBe(ctx.config.forumDid); 100 + }); 101 + 102 + it("returns null when forum does not exist", async () => { 103 + const forumUri = `at://did:plc:nonexistent/space.atbb.forum.forum/self`; 104 + 105 + const forum = await getForumByUri(ctx.db, forumUri); 106 + 107 + expect(forum).toBeNull(); 108 + }); 109 + 110 + it("returns null for invalid URI format", async () => { 111 + const invalidUris = [ 112 + "invalid-uri", 113 + "at://did:plc:test", // missing collection and rkey 114 + "at://did:plc:test/space.atbb.forum.forum", // missing rkey 115 + ]; 116 + 117 + for (const uri of invalidUris) { 118 + const forum = await getForumByUri(ctx.db, uri); 119 + expect(forum).toBeNull(); 120 + } 121 + }); 122 + }); 123 + 124 + describe("getPostsByIds", () => { 125 + let ctx: TestContext; 126 + let topicId: bigint; 127 + let replyId: bigint; 128 + 129 + beforeEach(async () => { 130 + ctx = await createTestContext(); 131 + 132 + // Insert test user 133 + await ctx.db.insert(users).values({ 134 + did: "did:plc:test-user", 135 + handle: "testuser.test", 136 + indexedAt: new Date(), 137 + }); 138 + 139 + // Insert topic post first 140 + const [topic] = await ctx.db.insert(posts).values({ 141 + did: "did:plc:test-user", 142 + rkey: "3lbk7topic", 143 + cid: "bafytopic", 144 + text: "Topic post", 145 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 146 + createdAt: new Date(), 147 + indexedAt: new Date(), 148 + deleted: false, 149 + }).returning(); 150 + topicId = topic.id; 151 + 152 + // Insert reply post referencing the topic 153 + const [reply] = await ctx.db.insert(posts).values({ 154 + did: "did:plc:test-user", 155 + rkey: "3lbk8reply", 156 + cid: "bafyreply", 157 + text: "Reply post", 158 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 159 + rootPostId: topic.id, 160 + parentPostId: topic.id, 161 + rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 162 + parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 163 + createdAt: new Date(), 164 + indexedAt: new Date(), 165 + deleted: false, 166 + }).returning(); 167 + replyId = reply.id; 168 + }); 169 + 170 + afterEach(async () => { 171 + await ctx.cleanup(); 172 + }); 173 + 174 + it("returns posts when they exist", async () => { 175 + const result = await getPostsByIds(ctx.db, [topicId, replyId]); 176 + 177 + expect(result.size).toBe(2); 178 + expect(result.get(topicId)?.rkey).toBe("3lbk7topic"); 179 + expect(result.get(replyId)?.rkey).toBe("3lbk8reply"); 180 + }); 181 + 182 + it("excludes deleted posts", async () => { 183 + // Mark topic as deleted 184 + await ctx.db 185 + .update(posts) 186 + .set({ deleted: true }) 187 + .where(eq(posts.id, topicId)); 188 + 189 + const result = await getPostsByIds(ctx.db, [topicId, replyId]); 190 + 191 + expect(result.size).toBe(1); 192 + expect(result.has(topicId)).toBe(false); 193 + expect(result.has(replyId)).toBe(true); 194 + }); 195 + 196 + it("returns empty map for non-existent IDs", async () => { 197 + const result = await getPostsByIds(ctx.db, [999n]); 198 + 199 + expect(result.size).toBe(0); 200 + }); 201 + }); 202 + 203 + describe("validateReplyParent", () => { 204 + it("accepts when parent IS the root", () => { 205 + const root = { id: 1n, rootPostId: null }; 206 + const parent = { id: 1n, rootPostId: null }; 207 + 208 + const result = validateReplyParent(root, parent, 1n); 209 + 210 + expect(result.valid).toBe(true); 211 + }); 212 + 213 + it("accepts when parent is a reply in same thread", () => { 214 + const root = { id: 1n, rootPostId: null }; 215 + const parent = { id: 2n, rootPostId: 1n }; 216 + 217 + const result = validateReplyParent(root, parent, 1n); 218 + 219 + expect(result.valid).toBe(true); 220 + }); 221 + 222 + it("rejects when parent belongs to different thread", () => { 223 + const root = { id: 1n, rootPostId: null }; 224 + const parent = { id: 2n, rootPostId: 99n }; // Different root 225 + 226 + const result = validateReplyParent(root, parent, 1n); 227 + 228 + expect(result.valid).toBe(false); 229 + expect(result.error).toContain("does not belong to this thread"); 230 + }); 231 + 232 + it("rejects when parent is a root but not THE root", () => { 233 + const root = { id: 1n, rootPostId: null }; 234 + const parent = { id: 2n, rootPostId: null }; // Also a root, but different 235 + 236 + const result = validateReplyParent(root, parent, 1n); 237 + 238 + expect(result.valid).toBe(false); 239 + expect(result.error).toContain("does not belong to this thread"); 240 + }); 241 + });
+348 -10
apps/appview/src/routes/__tests__/posts.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 2 import { Hono } from "hono"; 3 - import { apiRoutes } from "../index.js"; 4 5 - const app = new Hono().route("/api", apiRoutes); 6 7 describe("POST /api/posts", () => { 8 - it("returns 501 not implemented", async () => { 9 - const res = await app.request("/api/posts", { method: "POST" }); 10 - expect(res.status).toBe(501); 11 }); 12 13 - it("returns an error message", async () => { 14 - const res = await app.request("/api/posts", { method: "POST" }); 15 - const body = await res.json(); 16 - expect(body).toHaveProperty("error", "not implemented"); 17 }); 18 });
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 import { Hono } from "hono"; 3 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 + import type { Variables } from "../../types.js"; 5 + import { posts, users } from "@atbb/db"; 6 + 7 + // Mock requireAuth at the module level 8 + let mockPutRecord: ReturnType<typeof vi.fn>; 9 + let mockUser: any; 10 + 11 + vi.mock("../../middleware/auth.js", () => ({ 12 + requireAuth: vi.fn(() => async (c: any, next: any) => { 13 + c.set("user", mockUser); 14 + await next(); 15 + }), 16 + })); 17 18 + // Import after mocking 19 + const { createPostsRoutes } = await import("../posts.js"); 20 21 describe("POST /api/posts", () => { 22 + let ctx: TestContext; 23 + let app: Hono<{ Variables: Variables }>; 24 + let topicId: string; 25 + let replyId: string; 26 + 27 + beforeEach(async () => { 28 + ctx = await createTestContext(); 29 + 30 + // Insert test user 31 + await ctx.db.insert(users).values({ 32 + did: "did:plc:test-user", 33 + handle: "testuser.test", 34 + indexedAt: new Date(), 35 + }); 36 + 37 + // Insert topic (root post) and get its ID 38 + const [topicPost] = await ctx.db 39 + .insert(posts) 40 + .values({ 41 + did: "did:plc:test-user", 42 + rkey: "3lbk7topic", 43 + cid: "bafytopic", 44 + text: "Topic post", 45 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 46 + createdAt: new Date(), 47 + indexedAt: new Date(), 48 + deleted: false, 49 + }) 50 + .returning({ id: posts.id }); 51 + 52 + // Store topic ID for tests 53 + topicId = topicPost.id.toString(); 54 + 55 + // Insert reply and get its ID 56 + const [replyPost] = await ctx.db 57 + .insert(posts) 58 + .values({ 59 + did: "did:plc:test-user", 60 + rkey: "3lbk8reply", 61 + cid: "bafyreply", 62 + text: "Reply post", 63 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 64 + rootPostId: topicPost.id, 65 + parentPostId: topicPost.id, 66 + rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 67 + parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 68 + createdAt: new Date(), 69 + indexedAt: new Date(), 70 + deleted: false, 71 + }) 72 + .returning({ id: posts.id }); 73 + 74 + // Store reply ID for tests 75 + replyId = replyPost.id.toString(); 76 + 77 + // Mock putRecord to track calls 78 + mockPutRecord = vi.fn(async () => ({ 79 + data: { 80 + uri: "at://did:plc:test-user/space.atbb.post/3lbk9test", 81 + cid: "bafytest", 82 + }, 83 + })); 84 + 85 + // Set up mock user for auth middleware 86 + mockUser = { 87 + did: "did:plc:test-user", 88 + handle: "testuser.test", 89 + pdsUrl: "https://test.pds", 90 + agent: { 91 + com: { 92 + atproto: { 93 + repo: { 94 + putRecord: mockPutRecord, 95 + }, 96 + }, 97 + }, 98 + }, 99 + }; 100 + 101 + app = new Hono<{ Variables: Variables }>(); 102 + app.route("/api/posts", createPostsRoutes(ctx)); 103 + }); 104 + 105 + afterEach(async () => { 106 + await ctx.cleanup(); 107 + }); 108 + 109 + it("creates reply to topic", async () => { 110 + const res = await app.request("/api/posts", { 111 + method: "POST", 112 + headers: { "Content-Type": "application/json" }, 113 + body: JSON.stringify({ 114 + text: "My reply", 115 + rootPostId: topicId, 116 + parentPostId: topicId, 117 + }), 118 + }); 119 + 120 + expect(res.status).toBe(201); 121 + const data = await res.json(); 122 + expect(data.uri).toBeTruthy(); 123 + expect(data.cid).toBeTruthy(); 124 + expect(data.rkey).toBeTruthy(); 125 + }); 126 + 127 + it("creates reply to reply", async () => { 128 + const res = await app.request("/api/posts", { 129 + method: "POST", 130 + headers: { "Content-Type": "application/json" }, 131 + body: JSON.stringify({ 132 + text: "Nested reply", 133 + rootPostId: topicId, 134 + parentPostId: replyId, // Reply to the reply 135 + }), 136 + }); 137 + 138 + expect(res.status).toBe(201); 139 }); 140 141 + it("returns 400 for invalid parent ID format", async () => { 142 + const res = await app.request("/api/posts", { 143 + method: "POST", 144 + headers: { "Content-Type": "application/json" }, 145 + body: JSON.stringify({ 146 + text: "Test", 147 + rootPostId: "not-a-number", 148 + parentPostId: "1", 149 + }), 150 + }); 151 + 152 + expect(res.status).toBe(400); 153 + const data = await res.json(); 154 + expect(data.error).toContain("Invalid"); 155 + }); 156 + 157 + it("returns 404 when root post does not exist", async () => { 158 + const res = await app.request("/api/posts", { 159 + method: "POST", 160 + headers: { "Content-Type": "application/json" }, 161 + body: JSON.stringify({ 162 + text: "Test", 163 + rootPostId: "999", 164 + parentPostId: "999", 165 + }), 166 + }); 167 + 168 + expect(res.status).toBe(404); 169 + const data = await res.json(); 170 + expect(data.error).toContain("not found"); 171 + }); 172 + 173 + it("returns 404 when parent post does not exist", async () => { 174 + const res = await app.request("/api/posts", { 175 + method: "POST", 176 + headers: { "Content-Type": "application/json" }, 177 + body: JSON.stringify({ 178 + text: "Test", 179 + rootPostId: topicId, 180 + parentPostId: "999", 181 + }), 182 + }); 183 + 184 + expect(res.status).toBe(404); 185 + const data = await res.json(); 186 + expect(data.error).toContain("not found"); 187 + }); 188 + 189 + it("returns 400 when parent belongs to different thread", async () => { 190 + // Insert a different topic and get its ID 191 + const [otherTopic] = await ctx.db 192 + .insert(posts) 193 + .values({ 194 + did: "did:plc:test-user", 195 + rkey: "3lbkaother", 196 + cid: "bafyother", 197 + text: "Other topic", 198 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 199 + createdAt: new Date(), 200 + indexedAt: new Date(), 201 + deleted: false, 202 + }) 203 + .returning({ id: posts.id }); 204 + 205 + const res = await app.request("/api/posts", { 206 + method: "POST", 207 + headers: { "Content-Type": "application/json" }, 208 + body: JSON.stringify({ 209 + text: "Test", 210 + rootPostId: topicId, 211 + parentPostId: otherTopic.id.toString(), // Different thread 212 + }), 213 + }); 214 + 215 + expect(res.status).toBe(400); 216 + const data = await res.json(); 217 + expect(data.error).toContain("thread"); 218 + }); 219 + 220 + // Critical Issue #1: Test type guard for validatePostText 221 + it("returns 400 when text is missing", async () => { 222 + const res = await app.request("/api/posts", { 223 + method: "POST", 224 + headers: { "Content-Type": "application/json" }, 225 + body: JSON.stringify({ 226 + rootPostId: topicId, 227 + parentPostId: topicId, 228 + }), // No text field 229 + }); 230 + 231 + expect(res.status).toBe(400); 232 + const data = await res.json(); 233 + expect(data.error).toContain("Text is required"); 234 + }); 235 + 236 + it("returns 400 for non-string text (array)", async () => { 237 + const res = await app.request("/api/posts", { 238 + method: "POST", 239 + headers: { "Content-Type": "application/json" }, 240 + body: JSON.stringify({ 241 + text: ["not", "a", "string"], 242 + rootPostId: topicId, 243 + parentPostId: topicId, 244 + }), 245 + }); 246 + 247 + expect(res.status).toBe(400); 248 + const data = await res.json(); 249 + expect(data.error).toContain("must be a string"); 250 + }); 251 + 252 + // Critical Issue #2: Test malformed JSON handling 253 + it("returns 400 for malformed JSON", async () => { 254 + const res = await app.request("/api/posts", { 255 + method: "POST", 256 + headers: { "Content-Type": "application/json" }, 257 + body: '{"text": "incomplete', 258 + }); 259 + 260 + expect(res.status).toBe(400); 261 + const data = await res.json(); 262 + expect(data.error).toContain("Invalid JSON"); 263 + }); 264 + 265 + // Critical test coverage: PDS network errors (503) 266 + it("returns 503 when PDS connection fails (network error)", async () => { 267 + mockPutRecord.mockRejectedValueOnce(new Error("Network request failed")); 268 + 269 + const res = await app.request("/api/posts", { 270 + method: "POST", 271 + headers: { "Content-Type": "application/json" }, 272 + body: JSON.stringify({ 273 + text: "Test reply", 274 + rootPostId: topicId, 275 + parentPostId: topicId, 276 + }), 277 + }); 278 + 279 + expect(res.status).toBe(503); 280 + const data = await res.json(); 281 + expect(data.error).toContain("Unable to reach your PDS"); 282 + }); 283 + 284 + it("returns 503 when DNS resolution fails (ENOTFOUND)", async () => { 285 + mockPutRecord.mockRejectedValueOnce(new Error("getaddrinfo ENOTFOUND")); 286 + 287 + const res = await app.request("/api/posts", { 288 + method: "POST", 289 + headers: { "Content-Type": "application/json" }, 290 + body: JSON.stringify({ 291 + text: "Test reply", 292 + rootPostId: topicId, 293 + parentPostId: topicId, 294 + }), 295 + }); 296 + 297 + expect(res.status).toBe(503); 298 + const data = await res.json(); 299 + expect(data.error).toContain("Unable to reach your PDS"); 300 + }); 301 + 302 + it("returns 503 when request times out", async () => { 303 + mockPutRecord.mockRejectedValueOnce(new Error("timeout of 5000ms exceeded")); 304 + 305 + const res = await app.request("/api/posts", { 306 + method: "POST", 307 + headers: { "Content-Type": "application/json" }, 308 + body: JSON.stringify({ 309 + text: "Test reply", 310 + rootPostId: topicId, 311 + parentPostId: topicId, 312 + }), 313 + }); 314 + 315 + expect(res.status).toBe(503); 316 + const data = await res.json(); 317 + expect(data.error).toContain("Unable to reach your PDS"); 318 + }); 319 + 320 + // Critical test coverage: PDS server errors (500) 321 + it("returns 500 when PDS returns server error", async () => { 322 + mockPutRecord.mockRejectedValueOnce(new Error("PDS internal error")); 323 + 324 + const res = await app.request("/api/posts", { 325 + method: "POST", 326 + headers: { "Content-Type": "application/json" }, 327 + body: JSON.stringify({ 328 + text: "Test reply", 329 + rootPostId: topicId, 330 + parentPostId: topicId, 331 + }), 332 + }); 333 + 334 + expect(res.status).toBe(500); 335 + const data = await res.json(); 336 + expect(data.error).toContain("Failed to create post"); 337 + }); 338 + 339 + it("returns 500 for unexpected errors", async () => { 340 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected error occurred")); 341 + 342 + const res = await app.request("/api/posts", { 343 + method: "POST", 344 + headers: { "Content-Type": "application/json" }, 345 + body: JSON.stringify({ 346 + text: "Test reply", 347 + rootPostId: topicId, 348 + parentPostId: topicId, 349 + }), 350 + }); 351 + 352 + expect(res.status).toBe(500); 353 + const data = await res.json(); 354 + expect(data.error).toContain("Failed to create post"); 355 }); 356 });
+268 -18
apps/appview/src/routes/__tests__/topics.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 2 import { Hono } from "hono"; 3 - import { apiRoutes } from "../index.js"; 4 5 - const app = new Hono().route("/api", apiRoutes); 6 7 describe("GET /api/topics/:id", () => { 8 - it("returns 200", async () => { 9 - const res = await app.request("/api/topics/abc123"); 10 - expect(res.status).toBe(200); 11 }); 12 13 - it("echoes the topic id and returns expected shape", async () => { 14 const res = await app.request("/api/topics/abc123"); 15 const body = await res.json(); 16 - expect(body).toHaveProperty("topicId", "abc123"); 17 - expect(body).toHaveProperty("post"); 18 - expect(body).toHaveProperty("replies"); 19 - expect(Array.isArray(body.replies)).toBe(true); 20 }); 21 }); 22 23 describe("POST /api/topics", () => { 24 - it("returns 501 not implemented", async () => { 25 - const res = await app.request("/api/topics", { method: "POST" }); 26 - expect(res.status).toBe(501); 27 }); 28 29 - it("returns an error message", async () => { 30 - const res = await app.request("/api/topics", { method: "POST" }); 31 - const body = await res.json(); 32 - expect(body).toHaveProperty("error", "not implemented"); 33 }); 34 });
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from "vitest"; 2 import { Hono } from "hono"; 3 + import type { Variables } from "../../types.js"; 4 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 5 6 + // Mock requireAuth at the module level 7 + let mockPutRecord: ReturnType<typeof vi.fn>; 8 + let mockUser: any; 9 + 10 + vi.mock("../../middleware/auth.js", () => ({ 11 + requireAuth: vi.fn(() => async (c: any, next: any) => { 12 + c.set("user", mockUser); 13 + await next(); 14 + }), 15 + })); 16 + 17 + // Import after mocking 18 + const { createTopicsRoutes } = await import("../topics.js"); 19 20 describe("GET /api/topics/:id", () => { 21 + let ctx: TestContext; 22 + let app: Hono; 23 + 24 + beforeEach(async () => { 25 + ctx = await createTestContext(); 26 + app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 27 + }); 28 + 29 + afterEach(async () => { 30 + await ctx.cleanup(); 31 }); 32 33 + it("returns 404 for non-existent topic", async () => { 34 + // Use a valid bigint ID that doesn't exist 35 + const res = await app.request("/api/topics/123456789"); 36 + expect(res.status).toBe(404); 37 + const body = await res.json(); 38 + expect(body).toHaveProperty("error", "Topic not found"); 39 + }); 40 + 41 + it("returns 400 for invalid topic ID format", async () => { 42 const res = await app.request("/api/topics/abc123"); 43 + expect(res.status).toBe(400); 44 const body = await res.json(); 45 + expect(body).toHaveProperty("error", "Invalid topic ID format"); 46 }); 47 }); 48 49 describe("POST /api/topics", () => { 50 + let ctx: TestContext; 51 + let app: Hono<{ Variables: Variables }>; 52 + 53 + beforeEach(async () => { 54 + ctx = await createTestContext(); 55 + 56 + // Mock putRecord to track calls 57 + mockPutRecord = vi.fn(async () => ({ 58 + data: { 59 + uri: "at://did:plc:test-user/space.atbb.post/3lbk7test", 60 + cid: "bafytest", 61 + }, 62 + })); 63 + 64 + // Set up mock user for auth middleware 65 + mockUser = { 66 + did: "did:plc:test-user", 67 + handle: "testuser.test", 68 + pdsUrl: "https://test.pds", 69 + agent: { 70 + com: { 71 + atproto: { 72 + repo: { 73 + putRecord: mockPutRecord, 74 + }, 75 + }, 76 + }, 77 + }, 78 + }; 79 + 80 + app = new Hono<{ Variables: Variables }>(); 81 + app.route("/api/topics", createTopicsRoutes(ctx)); 82 + }); 83 + 84 + afterEach(async () => { 85 + await ctx.cleanup(); 86 + }); 87 + 88 + it("creates topic with valid text", async () => { 89 + const res = await app.request("/api/topics", { 90 + method: "POST", 91 + headers: { "Content-Type": "application/json" }, 92 + body: JSON.stringify({ text: "Hello, atBB!" }), 93 + }); 94 + 95 + expect(res.status).toBe(201); 96 + const data = await res.json(); 97 + expect(data.uri).toMatch(/^at:\/\/did:plc:test-user\/space\.atbb\.post\/3/); 98 + expect(data.cid).toBeTruthy(); 99 + expect(data.rkey).toBeTruthy(); 100 + }); 101 + 102 + it("returns 400 for empty text", async () => { 103 + const res = await app.request("/api/topics", { 104 + method: "POST", 105 + headers: { "Content-Type": "application/json" }, 106 + body: JSON.stringify({ text: " " }), 107 + }); 108 + 109 + expect(res.status).toBe(400); 110 + const data = await res.json(); 111 + expect(data.error).toContain("empty"); 112 }); 113 114 + it("returns 400 for text exceeding 300 graphemes", async () => { 115 + const res = await app.request("/api/topics", { 116 + method: "POST", 117 + headers: { "Content-Type": "application/json" }, 118 + body: JSON.stringify({ text: "a".repeat(301) }), 119 + }); 120 + 121 + expect(res.status).toBe(400); 122 + const data = await res.json(); 123 + expect(data.error).toContain("300 characters"); 124 + }); 125 + 126 + it("uses default forum URI when not provided", async () => { 127 + const res = await app.request("/api/topics", { 128 + method: "POST", 129 + headers: { "Content-Type": "application/json" }, 130 + body: JSON.stringify({ text: "Test topic" }), 131 + }); 132 + 133 + expect(res.status).toBe(201); 134 + // Verify putRecord was called with correct forum ref 135 + expect(mockPutRecord).toHaveBeenCalledWith( 136 + expect.objectContaining({ 137 + record: expect.objectContaining({ 138 + forum: expect.objectContaining({ 139 + forum: expect.objectContaining({ 140 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 141 + }), 142 + }), 143 + }), 144 + }) 145 + ); 146 + }); 147 + 148 + it("returns 404 when custom forum does not exist", async () => { 149 + const res = await app.request("/api/topics", { 150 + method: "POST", 151 + headers: { "Content-Type": "application/json" }, 152 + body: JSON.stringify({ 153 + text: "Test", 154 + forumUri: "at://did:plc:nonexistent/space.atbb.forum.forum/self", 155 + }), 156 + }); 157 + 158 + expect(res.status).toBe(404); 159 + const data = await res.json(); 160 + expect(data.error).toContain("Forum not found"); 161 + }); 162 + 163 + // Critical Issue #1: Test type guard for validatePostText 164 + it("returns 400 when text is missing", async () => { 165 + const res = await app.request("/api/topics", { 166 + method: "POST", 167 + headers: { "Content-Type": "application/json" }, 168 + body: JSON.stringify({}), // No text field 169 + }); 170 + 171 + expect(res.status).toBe(400); 172 + const data = await res.json(); 173 + expect(data.error).toContain("Text is required"); 174 + }); 175 + 176 + it("returns 400 for non-string text (number)", async () => { 177 + const res = await app.request("/api/topics", { 178 + method: "POST", 179 + headers: { "Content-Type": "application/json" }, 180 + body: JSON.stringify({ text: 123 }), 181 + }); 182 + 183 + expect(res.status).toBe(400); 184 + const data = await res.json(); 185 + expect(data.error).toContain("must be a string"); 186 + }); 187 + 188 + it("returns 400 for non-string text (null)", async () => { 189 + const res = await app.request("/api/topics", { 190 + method: "POST", 191 + headers: { "Content-Type": "application/json" }, 192 + body: JSON.stringify({ text: null }), 193 + }); 194 + 195 + expect(res.status).toBe(400); 196 + const data = await res.json(); 197 + expect(data.error).toContain("must be a string"); 198 + }); 199 + 200 + // Critical Issue #2: Test malformed JSON handling 201 + it("returns 400 for malformed JSON", async () => { 202 + const res = await app.request("/api/topics", { 203 + method: "POST", 204 + headers: { "Content-Type": "application/json" }, 205 + body: "{invalid json", 206 + }); 207 + 208 + expect(res.status).toBe(400); 209 + const data = await res.json(); 210 + expect(data.error).toContain("Invalid JSON"); 211 + }); 212 + 213 + // Critical test coverage: PDS network errors (503) 214 + it("returns 503 when PDS connection fails (fetch failed)", async () => { 215 + mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 216 + 217 + const res = await app.request("/api/topics", { 218 + method: "POST", 219 + headers: { "Content-Type": "application/json" }, 220 + body: JSON.stringify({ text: "Test topic" }), 221 + }); 222 + 223 + expect(res.status).toBe(503); 224 + const data = await res.json(); 225 + expect(data.error).toContain("Unable to reach your PDS"); 226 + }); 227 + 228 + it("returns 503 when PDS connection times out", async () => { 229 + mockPutRecord.mockRejectedValueOnce(new Error("Request timeout")); 230 + 231 + const res = await app.request("/api/topics", { 232 + method: "POST", 233 + headers: { "Content-Type": "application/json" }, 234 + body: JSON.stringify({ text: "Test topic" }), 235 + }); 236 + 237 + expect(res.status).toBe(503); 238 + const data = await res.json(); 239 + expect(data.error).toContain("Unable to reach your PDS"); 240 + }); 241 + 242 + it("returns 503 when PDS connection refused (ECONNREFUSED)", async () => { 243 + mockPutRecord.mockRejectedValueOnce(new Error("connect ECONNREFUSED")); 244 + 245 + const res = await app.request("/api/topics", { 246 + method: "POST", 247 + headers: { "Content-Type": "application/json" }, 248 + body: JSON.stringify({ text: "Test topic" }), 249 + }); 250 + 251 + expect(res.status).toBe(503); 252 + const data = await res.json(); 253 + expect(data.error).toContain("Unable to reach your PDS"); 254 + }); 255 + 256 + // Critical test coverage: PDS server errors (500) 257 + it("returns 500 when PDS has internal server error", async () => { 258 + mockPutRecord.mockRejectedValueOnce(new Error("Internal Server Error")); 259 + 260 + const res = await app.request("/api/topics", { 261 + method: "POST", 262 + headers: { "Content-Type": "application/json" }, 263 + body: JSON.stringify({ text: "Test topic" }), 264 + }); 265 + 266 + expect(res.status).toBe(500); 267 + const data = await res.json(); 268 + expect(data.error).toContain("Failed to create topic"); 269 + }); 270 + 271 + it("returns 500 for unexpected database errors", async () => { 272 + mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 273 + 274 + const res = await app.request("/api/topics", { 275 + method: "POST", 276 + headers: { "Content-Type": "application/json" }, 277 + body: JSON.stringify({ text: "Test topic" }), 278 + }); 279 + 280 + expect(res.status).toBe(500); 281 + const data = await res.json(); 282 + expect(data.error).toContain("Failed to create topic"); 283 }); 284 });
+150 -4
apps/appview/src/routes/helpers.ts
··· 1 - import type { users } from "@atbb/db"; 2 3 /** 4 * Parse a route parameter as BigInt. ··· 36 37 /** 38 * Safely serialize a BigInt to string. 39 - * Returns "0" if value is null or undefined. 40 */ 41 - export function serializeBigInt(value: bigint | null | undefined): string { 42 - if (value === null || value === undefined) return "0"; 43 return value.toString(); 44 } 45 ··· 53 } 54 return value.toISOString(); 55 }
··· 1 + import { users, forums, posts } from "@atbb/db"; 2 + import type { Database } from "@atbb/db"; 3 + import { eq, and, inArray } from "drizzle-orm"; 4 + import { UnicodeString } from "@atproto/api"; 5 6 /** 7 * Parse a route parameter as BigInt. ··· 39 40 /** 41 * Safely serialize a BigInt to string. 42 + * Returns null if value is null or undefined (avoids fabricating data). 43 */ 44 + export function serializeBigInt(value: bigint | null | undefined): string | null { 45 + if (value === null || value === undefined) { 46 + console.warn("serializeBigInt received null/undefined value", { 47 + operation: "serializeBigInt", 48 + stack: new Error().stack, 49 + }); 50 + return null; 51 + } 52 return value.toString(); 53 } 54 ··· 62 } 63 return value.toISOString(); 64 } 65 + 66 + /** 67 + * Validate post text according to lexicon constraints. 68 + * - Max 300 graphemes (user-perceived characters) 69 + * - Non-empty after trimming whitespace 70 + */ 71 + export function validatePostText(text: unknown): { 72 + valid: boolean; 73 + trimmed?: string; 74 + error?: string; 75 + } { 76 + // Type guard: ensure text is a string 77 + if (typeof text !== "string") { 78 + return { valid: false, error: "Text is required and must be a string" }; 79 + } 80 + 81 + const trimmed = text.trim(); 82 + 83 + if (trimmed.length === 0) { 84 + return { valid: false, error: "Text cannot be empty" }; 85 + } 86 + 87 + const graphemeLength = new UnicodeString(trimmed).graphemeLength; 88 + if (graphemeLength > 300) { 89 + return { 90 + valid: false, 91 + error: "Text must be 300 characters or less", 92 + }; 93 + } 94 + 95 + return { valid: true, trimmed }; 96 + } 97 + 98 + /** 99 + * Look up forum by AT-URI. 100 + * Returns null if forum doesn't exist. 101 + * 102 + * @param db Database instance 103 + * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.forum/self" 104 + */ 105 + export async function getForumByUri( 106 + db: Database, 107 + uri: string 108 + ): Promise<{ did: string; rkey: string; cid: string } | null> { 109 + // Parse AT-URI: at://did/collection/rkey 110 + const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/); 111 + if (!match) { 112 + return null; 113 + } 114 + 115 + const [, did, rkey] = match; 116 + 117 + try { 118 + const [forum] = await db 119 + .select({ 120 + did: forums.did, 121 + rkey: forums.rkey, 122 + cid: forums.cid, 123 + }) 124 + .from(forums) 125 + .where(and(eq(forums.did, did), eq(forums.rkey, rkey))) 126 + .limit(1); 127 + 128 + return forum ?? null; 129 + } catch (error) { 130 + console.error("Failed to query forum by URI", { 131 + operation: "getForumByUri", 132 + uri, 133 + did, 134 + rkey, 135 + error: error instanceof Error ? error.message : String(error), 136 + }); 137 + throw error; 138 + } 139 + } 140 + 141 + export type PostRow = typeof posts.$inferSelect; 142 + 143 + /** 144 + * Look up multiple posts by ID in a single query. 145 + * Excludes deleted posts. 146 + * Returns a Map for O(1) lookup. 147 + */ 148 + export async function getPostsByIds( 149 + db: Database, 150 + ids: bigint[] 151 + ): Promise<Map<bigint, PostRow>> { 152 + if (ids.length === 0) { 153 + return new Map(); 154 + } 155 + 156 + try { 157 + const results = await db 158 + .select() 159 + .from(posts) 160 + .where(and(inArray(posts.id, ids), eq(posts.deleted, false))); 161 + 162 + return new Map(results.map((post) => [post.id, post])); 163 + } catch (error) { 164 + console.error("Failed to query posts by IDs", { 165 + operation: "getPostsByIds", 166 + ids: ids.map(String), 167 + error: error instanceof Error ? error.message : String(error), 168 + }); 169 + throw error; 170 + } 171 + } 172 + 173 + /** 174 + * Validate that a parent post belongs to the same thread as the root. 175 + * 176 + * Rules: 177 + * - Parent can BE the root (replying directly to topic) 178 + * - Parent can be a reply in the same thread (parent.rootPostId === rootId) 179 + * - Parent cannot belong to a different thread 180 + */ 181 + export function validateReplyParent( 182 + root: { id: bigint; rootPostId: bigint | null }, 183 + parent: { id: bigint; rootPostId: bigint | null }, 184 + rootId: bigint 185 + ): { valid: boolean; error?: string } { 186 + // Parent IS the root (replying to topic) 187 + if (parent.id === rootId && parent.rootPostId === null) { 188 + return { valid: true }; 189 + } 190 + 191 + // Parent is a reply in the same thread 192 + if (parent.rootPostId === rootId) { 193 + return { valid: true }; 194 + } 195 + 196 + // Parent belongs to a different thread 197 + return { 198 + valid: false, 199 + error: "Parent post does not belong to this thread", 200 + }; 201 + }
+7 -3
apps/appview/src/routes/index.ts
··· 4 import { createForumRoutes } from "./forum.js"; 5 import { createCategoriesRoutes } from "./categories.js"; 6 import { createTopicsRoutes } from "./topics.js"; 7 - import { postsRoutes } from "./posts.js"; 8 import { createAuthRoutes } from "./auth.js"; 9 10 /** ··· 17 .route("/forum", createForumRoutes(ctx)) 18 .route("/categories", createCategoriesRoutes(ctx)) 19 .route("/topics", createTopicsRoutes(ctx)) 20 - .route("/posts", postsRoutes); 21 } 22 23 // Export stub routes for tests that don't need database access ··· 40 }) 41 .post("/", (c) => c.json({ error: "not implemented" }, 501)); 42 43 export const apiRoutes = new Hono() 44 .route("/healthz", healthRoutes) 45 .route("/forum", stubForumRoutes) 46 .route("/categories", stubCategoriesRoutes) 47 .route("/topics", stubTopicsRoutes) 48 - .route("/posts", postsRoutes);
··· 4 import { createForumRoutes } from "./forum.js"; 5 import { createCategoriesRoutes } from "./categories.js"; 6 import { createTopicsRoutes } from "./topics.js"; 7 + import { createPostsRoutes } from "./posts.js"; 8 import { createAuthRoutes } from "./auth.js"; 9 10 /** ··· 17 .route("/forum", createForumRoutes(ctx)) 18 .route("/categories", createCategoriesRoutes(ctx)) 19 .route("/topics", createTopicsRoutes(ctx)) 20 + .route("/posts", createPostsRoutes(ctx)); 21 } 22 23 // Export stub routes for tests that don't need database access ··· 40 }) 41 .post("/", (c) => c.json({ error: "not implemented" }, 501)); 42 43 + const stubPostsRoutes = new Hono().post("/", (c) => 44 + c.json({ error: "not implemented" }, 501) 45 + ); 46 + 47 export const apiRoutes = new Hono() 48 .route("/healthz", healthRoutes) 49 .route("/forum", stubForumRoutes) 50 .route("/categories", stubCategoriesRoutes) 51 .route("/topics", stubTopicsRoutes) 52 + .route("/posts", stubPostsRoutes);
+152 -4
apps/appview/src/routes/posts.ts
··· 1 import { Hono } from "hono"; 2 3 - export const postsRoutes = new Hono().post("/", (c) => { 4 - // Phase 1: create space.atbb.post record with forumRef and reply ref 5 - return c.json({ error: "not implemented" }, 501); 6 - });
··· 1 import { Hono } from "hono"; 2 + import { TID } from "@atproto/common-web"; 3 + import type { AppContext } from "../lib/app-context.js"; 4 + import { requireAuth } from "../middleware/auth.js"; 5 + import { 6 + validatePostText, 7 + parseBigIntParam, 8 + getPostsByIds, 9 + validateReplyParent, 10 + } from "./helpers.js"; 11 12 + export function createPostsRoutes(ctx: AppContext) { 13 + return new Hono().post("/", requireAuth(ctx), async (c) => { 14 + const user = c.get("user")!; 15 + 16 + // Parse and validate request body 17 + let body: any; 18 + try { 19 + body = await c.req.json(); 20 + } catch { 21 + return c.json({ error: "Invalid JSON in request body" }, 400); 22 + } 23 + 24 + const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body; 25 + 26 + // Validate text 27 + const validation = validatePostText(text); 28 + if (!validation.valid) { 29 + return c.json({ error: validation.error }, 400); 30 + } 31 + 32 + // Parse IDs 33 + const rootId = parseBigIntParam(rootIdStr); 34 + const parentId = parseBigIntParam(parentIdStr); 35 + 36 + if (rootId === null || parentId === null) { 37 + return c.json( 38 + { 39 + error: "Invalid post ID format. IDs must be numeric strings.", 40 + }, 41 + 400 42 + ); 43 + } 44 + 45 + try { 46 + // Look up root and parent posts 47 + const postsMap = await getPostsByIds(ctx.db, [rootId, parentId]); 48 + 49 + const root = postsMap.get(rootId); 50 + const parent = postsMap.get(parentId); 51 + 52 + if (!root) { 53 + return c.json({ error: "Root post not found" }, 404); 54 + } 55 + 56 + if (!parent) { 57 + return c.json({ error: "Parent post not found" }, 404); 58 + } 59 + 60 + // Validate parent belongs to same thread 61 + const parentValidation = validateReplyParent(root, parent, rootId); 62 + if (!parentValidation.valid) { 63 + return c.json({ error: parentValidation.error }, 400); 64 + } 65 + 66 + // Validate root post has forum reference 67 + if (!root.forumUri) { 68 + return c.json({ error: "Root post has no forum reference" }, 400); 69 + } 70 + 71 + // Construct AT-URIs 72 + const rootUri = `at://${root.did}/space.atbb.post/${root.rkey}`; 73 + const parentUri = `at://${parent.did}/space.atbb.post/${parent.rkey}`; 74 + 75 + // Generate TID for rkey 76 + const rkey = TID.nextStr(); 77 + 78 + // Write to user's PDS 79 + const result = await user.agent.com.atproto.repo.putRecord({ 80 + repo: user.did, 81 + collection: "space.atbb.post", 82 + rkey, 83 + record: { 84 + $type: "space.atbb.post", 85 + text: validation.trimmed!, 86 + forum: { 87 + forum: { uri: root.forumUri, cid: root.cid }, 88 + }, 89 + reply: { 90 + root: { uri: rootUri, cid: root.cid }, 91 + parent: { uri: parentUri, cid: parent.cid }, 92 + }, 93 + createdAt: new Date().toISOString(), 94 + }, 95 + }); 96 + 97 + return c.json( 98 + { 99 + uri: result.data.uri, 100 + cid: result.data.cid, 101 + rkey, 102 + }, 103 + 201 104 + ); 105 + } catch (error) { 106 + // Re-throw programming bugs (don't catch TypeError, ReferenceError) 107 + if (error instanceof TypeError || error instanceof ReferenceError) { 108 + console.error("CRITICAL: Programming error in POST /api/posts", { 109 + operation: "POST /api/posts", 110 + userId: user.did, 111 + rootId: rootIdStr, 112 + parentId: parentIdStr, 113 + error: error instanceof Error ? error.message : String(error), 114 + stack: error instanceof Error ? error.stack : undefined, 115 + }); 116 + throw error; // Let global error handler catch it 117 + } 118 + 119 + console.error("Failed to create post", { 120 + operation: "POST /api/posts", 121 + userId: user.did, 122 + rootId: rootIdStr, 123 + parentId: parentIdStr, 124 + error: error instanceof Error ? error.message : String(error), 125 + }); 126 + 127 + // Distinguish network errors from server errors 128 + if (error instanceof Error) { 129 + const msg = error.message.toLowerCase(); 130 + if ( 131 + msg.includes("fetch failed") || 132 + msg.includes("network") || 133 + msg.includes("timeout") || 134 + msg.includes("econnrefused") || 135 + msg.includes("enotfound") 136 + ) { 137 + return c.json( 138 + { 139 + error: "Unable to reach your PDS. Please try again later.", 140 + }, 141 + 503 142 + ); 143 + } 144 + } 145 + 146 + return c.json( 147 + { 148 + error: "Failed to create post. Please try again later.", 149 + }, 150 + 500 151 + ); 152 + } 153 + }); 154 + }
+106 -4
apps/appview/src/routes/topics.ts
··· 2 import type { AppContext } from "../lib/app-context.js"; 3 import { posts, users } from "@atbb/db"; 4 import { eq, and, asc } from "drizzle-orm"; 5 import { 6 parseBigIntParam, 7 serializeAuthor, 8 serializeBigInt, 9 serializeDate, 10 } from "./helpers.js"; 11 12 /** ··· 89 ); 90 } 91 }) 92 - .post("/", (c) => { 93 - // Phase 2: create space.atbb.post record with forumRef but no reply ref 94 - // This requires authentication and PDS write operations 95 - return c.json({ error: "not implemented" }, 501); 96 }); 97 }
··· 2 import type { AppContext } from "../lib/app-context.js"; 3 import { posts, users } from "@atbb/db"; 4 import { eq, and, asc } from "drizzle-orm"; 5 + import { TID } from "@atproto/common-web"; 6 + import { requireAuth } from "../middleware/auth.js"; 7 import { 8 parseBigIntParam, 9 serializeAuthor, 10 serializeBigInt, 11 serializeDate, 12 + validatePostText, 13 + getForumByUri, 14 } from "./helpers.js"; 15 16 /** ··· 93 ); 94 } 95 }) 96 + .post("/", requireAuth(ctx), async (c) => { 97 + // user is guaranteed to exist after requireAuth middleware 98 + const user = c.get("user")!; 99 + 100 + // Parse and validate request body 101 + let body: any; 102 + try { 103 + body = await c.req.json(); 104 + } catch { 105 + return c.json({ error: "Invalid JSON in request body" }, 400); 106 + } 107 + 108 + const { text, forumUri: customForumUri } = body; 109 + 110 + // Validate text 111 + const validation = validatePostText(text); 112 + if (!validation.valid) { 113 + return c.json({ error: validation.error }, 400); 114 + } 115 + 116 + try { 117 + // Resolve forum URI (default to singleton forum) 118 + const forumUri = 119 + customForumUri ?? 120 + `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 121 + 122 + // Look up forum to get CID 123 + const forum = await getForumByUri(ctx.db, forumUri); 124 + if (!forum) { 125 + return c.json({ error: "Forum not found" }, 404); 126 + } 127 + 128 + // Generate TID for rkey 129 + const rkey = TID.nextStr(); 130 + 131 + // Write to user's PDS 132 + const result = await user.agent.com.atproto.repo.putRecord({ 133 + repo: user.did, 134 + collection: "space.atbb.post", 135 + rkey, 136 + record: { 137 + $type: "space.atbb.post", 138 + text: validation.trimmed!, 139 + forum: { 140 + forum: { uri: forumUri, cid: forum.cid }, 141 + }, 142 + createdAt: new Date().toISOString(), 143 + }, 144 + }); 145 + 146 + return c.json( 147 + { 148 + uri: result.data.uri, 149 + cid: result.data.cid, 150 + rkey, 151 + }, 152 + 201 153 + ); 154 + } catch (error) { 155 + // Re-throw programming bugs (don't catch TypeError, ReferenceError) 156 + if (error instanceof TypeError || error instanceof ReferenceError) { 157 + console.error("CRITICAL: Programming error in POST /api/topics", { 158 + operation: "POST /api/topics", 159 + userId: user.did, 160 + error: error instanceof Error ? error.message : String(error), 161 + stack: error instanceof Error ? error.stack : undefined, 162 + }); 163 + throw error; // Let global error handler catch it 164 + } 165 + 166 + console.error("Failed to create topic", { 167 + operation: "POST /api/topics", 168 + userId: user.did, 169 + error: error instanceof Error ? error.message : String(error), 170 + }); 171 + 172 + // Distinguish network errors from server errors 173 + if (error instanceof Error) { 174 + const msg = error.message.toLowerCase(); 175 + if ( 176 + msg.includes("fetch failed") || 177 + msg.includes("network") || 178 + msg.includes("timeout") || 179 + msg.includes("econnrefused") || 180 + msg.includes("enotfound") 181 + ) { 182 + return c.json( 183 + { 184 + error: "Unable to reach your PDS. Please try again later.", 185 + }, 186 + 503 187 + ); 188 + } 189 + } 190 + 191 + return c.json( 192 + { 193 + error: "Failed to create topic. Please try again later.", 194 + }, 195 + 500 196 + ); 197 + } 198 }); 199 }
+57
apps/appview/vitest.config.ts
··· 1 import { defineConfig } from "vitest/config"; 2 3 export default defineConfig({ 4 test: { 5 environment: "node", 6 }, 7 });
··· 1 import { defineConfig } from "vitest/config"; 2 + import { readFileSync, existsSync, statSync } from "node:fs"; 3 + import { resolve, dirname } from "node:path"; 4 + import { fileURLToPath } from "node:url"; 5 + 6 + const __filename = fileURLToPath(import.meta.url); 7 + const __dirname = dirname(__filename); 8 + 9 + // Load .env file from monorepo root 10 + // Try to find git root directory by looking for .git (file or directory) 11 + function findGitRoot(startDir: string): string | null { 12 + let currentDir = startDir; 13 + while (currentDir !== dirname(currentDir)) { 14 + const gitPath = resolve(currentDir, ".git"); 15 + if (existsSync(gitPath)) { 16 + // In worktrees, .git is a file pointing to the main repo 17 + if (statSync(gitPath).isFile()) { 18 + const gitContent = readFileSync(gitPath, "utf-8"); 19 + const match = gitContent.match(/gitdir: (.+)/); 20 + if (match) { 21 + // gitdir points to .git/worktrees/<name>, go up two levels to main repo 22 + const mainGitDir = dirname(dirname(match[1].trim())); 23 + return dirname(mainGitDir); // Parent of .git is the repo root 24 + } 25 + } 26 + // Regular git repo, .git is a directory 27 + return currentDir; 28 + } 29 + currentDir = dirname(currentDir); 30 + } 31 + return null; 32 + } 33 + 34 + const gitRoot = findGitRoot(__dirname); 35 + const possibleEnvPaths = gitRoot 36 + ? [resolve(gitRoot, ".env")] 37 + : [resolve(__dirname, "../../.env")]; 38 + 39 + let env: Record<string, string> = {}; 40 + for (const envPath of possibleEnvPaths) { 41 + if (existsSync(envPath)) { 42 + const envContent = readFileSync(envPath, "utf-8"); 43 + for (const line of envContent.split("\n")) { 44 + const trimmed = line.trim(); 45 + if (trimmed && !trimmed.startsWith("#")) { 46 + const [key, ...values] = trimmed.split("="); 47 + if (key && values.length > 0) { 48 + env[key] = values.join("="); 49 + } 50 + } 51 + } 52 + break; 53 + } 54 + } 55 56 export default defineConfig({ 57 test: { 58 environment: "node", 59 + env, 60 + // Run test files sequentially to avoid database conflicts 61 + // Tests share a single test database and use the same test DIDs 62 + fileParallelism: false, 63 }, 64 });
+5 -3
docs/atproto-forum-plan.md
··· 158 - Global error handler in create-app.ts for unhandled errors 159 - Helper functions for serialization (serializeBigInt, serializeDate, serializeAuthor, parseBigIntParam) 160 - **Note:** `GET /api/categories/:id/topics` endpoint removed - posts table lacks categoryUri field for filtering (deferred to ATB-12 or later when schema supports category-to-post association) 161 - - [ ] API endpoints (write path — proxy to user's PDS) — **Scaffolded:** Routes exist but return 501 Not Implemented (ATB-12): 162 - - `POST /api/topics` — create `space.atbb.post` record with `forumRef` but no `reply` ref (needs implementation) 163 - - `POST /api/posts` — create `space.atbb.post` record with both `forumRef` and `reply` ref (needs implementation) 164 165 #### Phase 2: Auth & Membership (Week 5–6) 166 - [x] Implement AT Proto OAuth flow (user login via their PDS) — **Complete:** OAuth 2.1 implementation using `@atproto/oauth-client-node` library with PKCE flow, state validation, automatic token refresh, and DPoP. Supports any AT Protocol PDS (not limited to bsky.social). Routes in `apps/appview/src/routes/auth.ts` (ATB-14)
··· 158 - Global error handler in create-app.ts for unhandled errors 159 - Helper functions for serialization (serializeBigInt, serializeDate, serializeAuthor, parseBigIntParam) 160 - **Note:** `GET /api/categories/:id/topics` endpoint removed - posts table lacks categoryUri field for filtering (deferred to ATB-12 or later when schema supports category-to-post association) 161 + - [x] API endpoints (write path — proxy to user's PDS) — **DONE** (ATB-12): 162 + - `POST /api/topics` — create `space.atbb.post` record with `forumRef` but no `reply` ref. Validates text (1-300 graphemes), writes to user's PDS via OAuth agent, returns {uri, cid, rkey} with 201 status. Fire-and-forget design (firehose indexes asynchronously). (`apps/appview/src/routes/topics.ts:13-119`) 163 + - `POST /api/posts` — create `space.atbb.post` record with both `forumRef` and `reply` ref. Validates text, parses rootPostId/parentPostId, validates parent belongs to same thread, writes to user's PDS, returns {uri, cid, rkey}. (`apps/appview/src/routes/posts.ts:13-119`) 164 + - Helper functions for validation: `validateGraphemeLength()` (1-300 graphemes using `@atproto/api` UnicodeString), `getForumByUri()`, `getPostsByIds()` (bulk lookup with Map), `validateReplyParent()` (thread boundary validation). (`apps/appview/src/routes/helpers.ts:65-190`) 165 + - Tests: 7 integration tests for POST /api/topics, 6 integration tests for POST /api/posts, 16 unit tests for helpers. All 112 appview tests passing. 166 167 #### Phase 2: Auth & Membership (Week 5–6) 168 - [x] Implement AT Proto OAuth flow (user login via their PDS) — **Complete:** OAuth 2.1 implementation using `@atproto/oauth-client-node` library with PKCE flow, state validation, automatic token refresh, and DPoP. Supports any AT Protocol PDS (not limited to bsky.social). Routes in `apps/appview/src/routes/auth.ts` (ATB-14)
+320
docs/plans/2026-02-09-write-endpoints-design.md
···
··· 1 + # Write-Path API Endpoints Design (ATB-12) 2 + 3 + **Date:** 2026-02-09 4 + **Issue:** ATB-12 5 + **Status:** Design Approved 6 + 7 + ## Overview 8 + 9 + Build write-path API endpoints that proxy post creation to users' PDS servers. Users authenticate via OAuth (ATB-14). The AppView writes records to their personal data servers. The firehose subscriber indexes records asynchronously. 10 + 11 + ## Architecture & Request Flow 12 + 13 + **Overall Flow:** 14 + 1. User makes POST request to `/api/topics` or `/api/posts` 15 + 2. `requireAuth` middleware validates session, attaches authenticated `user` to context 16 + 3. Endpoint validates request body (text length, IDs, etc.) 17 + 4. For replies: Query database to verify parent and root posts exist and belong to same thread 18 + 5. Construct AT Protocol record structure (different for topics vs replies) 19 + 6. Call `user.agent.com.atproto.repo.putRecord()` to write to user's PDS 20 + 7. Return `{uri, cid, rkey}` immediately (201 Created) 21 + 8. **Separately:** Firehose subscriber picks up the record asynchronously (1-5 seconds later) 22 + 9. Indexer writes to AppView database 23 + 10. Record becomes visible in GET endpoints 24 + 25 + **Key Architectural Points:** 26 + - Write endpoints are **thin proxies**—they validate and forward to PDS 27 + - These endpoints never write directly to the database (the indexer handles that) 28 + - OAuth Agent from ATB-14 handles DPoP automatically 29 + - `@atproto/common-web` generates TIDs (already in dependencies) 30 + - Forum URI defaults to singleton `at://{FORUM_DID}/space.atbb.forum.forum/self` 31 + - Reply validation queries the database for parent and root URIs and CIDs 32 + 33 + **Data Flow Separation:** 34 + - **Write path:** User → AppView API → User's PDS → Firehose → Indexer → AppView DB 35 + - **Read path:** User → AppView API → AppView DB (already implemented in ATB-11) 36 + 37 + **Design Decision: Fire-and-Forget** 38 + - Endpoints return immediately after PDS write succeeds 39 + - No optimistic database writes (MVP keeps it simple) 40 + - Posts appear in API responses after a 1-5 second delay 41 + - Optimistic writes can be added later if UX demands it 42 + 43 + ## POST /api/topics - Create Thread Starter 44 + 45 + **Endpoint:** `POST /api/topics` 46 + 47 + **Authentication:** Required via `requireAuth` middleware 48 + 49 + **Request Body:** 50 + ```typescript 51 + { 52 + text: string; // Required: post content 53 + forumUri?: string; // Optional: defaults to singleton forum 54 + } 55 + ``` 56 + 57 + **Implementation Steps:** 58 + 59 + 1. **Extract authenticated user:** `const user = c.get('user')` (`requireAuth` middleware guarantees this exists) 60 + 61 + 2. **Validate text:** 62 + - Must be non-empty (trim whitespace first) 63 + - Max length: **300 graphemes** (per lexicon `space.atbb.post`) 64 + - Use `new UnicodeString(text).graphemeLength` from `@atproto/api` 65 + - Return 400 with clear message if invalid 66 + 67 + 3. **Resolve forum URI:** 68 + - If `forumUri` provided, use it 69 + - Otherwise, default to `at://{FORUM_DID}/space.atbb.forum.forum/self` 70 + - Load `FORUM_DID` from environment/config 71 + 72 + 4. **Query forum to get CID:** 73 + - Look up forum in database to get its current CID 74 + - Return 404 if forum doesn't exist (prevents writing to non-existent forums) 75 + 76 + 5. **Generate TID:** `const rkey = TID.nextStr()` from `@atproto/common-web` 77 + 78 + 6. **Write to PDS:** 79 + ```typescript 80 + const result = await user.agent.com.atproto.repo.putRecord({ 81 + repo: user.did, 82 + collection: "space.atbb.post", 83 + rkey, 84 + record: { 85 + $type: "space.atbb.post", 86 + text: requestBody.text, 87 + forum: { 88 + forum: { uri: forumUri, cid: forumCid } 89 + }, 90 + createdAt: new Date().toISOString() 91 + // NO reply field - this is a topic starter 92 + } 93 + }); 94 + ``` 95 + 96 + 7. **Return success:** 97 + ```typescript 98 + return c.json({ 99 + uri: result.uri, 100 + cid: result.cid, 101 + rkey 102 + }, 201); 103 + ``` 104 + 105 + **Success Response (201):** 106 + ```json 107 + { 108 + "uri": "at://did:plc:abc123/space.atbb.post/3lbk7foobar", 109 + "cid": "bafyreiexample", 110 + "rkey": "3lbk7foobar" 111 + } 112 + ``` 113 + 114 + ## POST /api/posts - Create Reply 115 + 116 + **Endpoint:** `POST /api/posts` 117 + 118 + **Authentication:** Required via `requireAuth` middleware 119 + 120 + **Request Body:** 121 + ```typescript 122 + { 123 + text: string; // Required: post content 124 + rootPostId: string; // Required: thread starter ID (bigint as string) 125 + parentPostId: string; // Required: direct parent ID (bigint as string) 126 + } 127 + ``` 128 + 129 + **Implementation Steps:** 130 + 131 + 1. **Extract authenticated user:** `const user = c.get('user')` 132 + 133 + 2. **Validate text:** Same as topics (non-empty, max 300 graphemes) 134 + 135 + 3. **Parse and validate IDs:** 136 + - Parse `rootPostId` and `parentPostId` using `parseBigIntParam()` 137 + - Return 400 if either is invalid format 138 + 139 + 4. **Query and validate parent/root posts:** 140 + - Fetch both posts in a single query (or two parallel queries) 141 + - Check that both exist and are not deleted 142 + - Check that parent belongs to the same thread: 143 + - If parent IS the root: `parent.rootPostId === null && parent.id === rootId` 144 + - If parent is a reply: `parent.rootPostId === rootId` 145 + - Return 404 with specific error if validation fails ("Parent post not found", "Parent does not belong to this thread") 146 + 147 + 5. **Extract URIs and CIDs:** 148 + - Construct root URI: `at://${root.did}/space.atbb.post/${root.rkey}` 149 + - Construct parent URI: `at://${parent.did}/space.atbb.post/${parent.rkey}` 150 + - Get CIDs from database: `root.cid`, `parent.cid` 151 + - Get forum URI from root post: `root.forumUri` 152 + 153 + 6. **Generate TID:** `const rkey = TID.nextStr()` 154 + 155 + 7. **Write to PDS:** 156 + ```typescript 157 + const result = await user.agent.com.atproto.repo.putRecord({ 158 + repo: user.did, 159 + collection: "space.atbb.post", 160 + rkey, 161 + record: { 162 + $type: "space.atbb.post", 163 + text: requestBody.text, 164 + forum: { 165 + forum: { uri: root.forumUri, cid: root.cid } // Inherit from root 166 + }, 167 + reply: { 168 + root: { uri: rootUri, cid: root.cid }, 169 + parent: { uri: parentUri, cid: parent.cid } 170 + }, 171 + createdAt: new Date().toISOString() 172 + } 173 + }); 174 + ``` 175 + 176 + 8. **Return success:** Same 201 response with `{uri, cid, rkey}` 177 + 178 + **Success Response (201):** 179 + ```json 180 + { 181 + "uri": "at://did:plc:xyz789/space.atbb.post/3lbk8bazqux", 182 + "cid": "bafyreiexample2", 183 + "rkey": "3lbk8bazqux" 184 + } 185 + ``` 186 + 187 + ## Error Handling 188 + 189 + **Error Categories and HTTP Status Codes:** 190 + 191 + 1. **Client errors (4xx):** 192 + - `400 Bad Request`: Invalid text (empty, too long), invalid ID format 193 + - `401 Unauthorized`: Session expired or missing (handled by `requireAuth`) 194 + - `404 Not Found`: Forum doesn't exist, parent/root post not found, parent not in same thread 195 + 196 + 2. **Server errors (5xx):** 197 + - `500 Internal Server Error`: Unexpected database errors, unexpected PDS errors 198 + - `503 Service Unavailable`: PDS unreachable (network timeout, PDS down) 199 + 200 + **Error Handling Pattern:** 201 + 202 + ```typescript 203 + try { 204 + // Database query for parent validation 205 + const [parent] = await ctx.db.select()...; 206 + 207 + if (!parent) { 208 + return c.json({ error: "Parent post not found" }, 404); 209 + } 210 + 211 + // PDS write 212 + const result = await user.agent.com.atproto.repo.putRecord(...); 213 + 214 + return c.json({ uri: result.uri, cid: result.cid, rkey }, 201); 215 + } catch (error) { 216 + console.error("Failed to create post", { 217 + operation: "POST /api/posts", 218 + userId: user.did, 219 + parentId: parentPostId, 220 + error: error instanceof Error ? error.message : String(error), 221 + }); 222 + 223 + // Distinguish network errors from unexpected errors 224 + if (error instanceof Error && error.message.includes("fetch failed")) { 225 + return c.json( 226 + { error: "Unable to reach your PDS. Please try again later." }, 227 + 503 228 + ); 229 + } 230 + 231 + return c.json( 232 + { error: "Failed to create post. Please try again later." }, 233 + 500 234 + ); 235 + } 236 + ``` 237 + 238 + **Edge Cases:** 239 + - Long text near grapheme limit (test with emoji) 240 + - Replying to a reply (3-level nesting) 241 + - Concurrent requests (TID uniqueness is guaranteed by `TID.nextStr()`) 242 + - PDS returns error (invalid record, quota exceeded) - treat as 500 243 + - **No retry logic:** If PDS write fails, return error immediately. User can retry manually. 244 + 245 + ## Testing Strategy 246 + 247 + **Unit Tests (Vitest):** 248 + 249 + 1. **Validation helpers:** 250 + - Test grapheme counting with ASCII, emoji, multi-byte characters 251 + - Test `parseBigIntParam()` with valid/invalid inputs 252 + - Test forum URI defaulting logic 253 + 254 + 2. **Request validation:** 255 + - Empty text → 400 256 + - Text with 301 graphemes → 400 257 + - Text with 300 graphemes → passes 258 + - Invalid parent ID format → 400 259 + 260 + 3. **Parent validation logic:** 261 + - Parent doesn't exist → 404 262 + - Parent is deleted → 404 263 + - Parent belongs to different thread → 404 264 + - Parent is the root (replying directly to topic) → passes 265 + - Parent is a reply in same thread → passes 266 + 267 + **Integration Tests (requires test PDS):** 268 + 269 + 1. **POST /api/topics:** 270 + - Create topic as authenticated user → 201 with `{uri, cid, rkey}` 271 + - Create topic without auth → 401 272 + - Create topic with empty text → 400 273 + - Create topic with non-existent forum → 404 274 + 275 + 2. **POST /api/posts:** 276 + - Create reply to topic → 201 277 + - Create reply to reply → 201 278 + - Reply to non-existent parent → 404 279 + - Reply with parent from different thread → 404 280 + 281 + **Manual Verification Flow:** 282 + 1. Authenticate via OAuth (use web UI or Postman) 283 + 2. POST /api/topics → get back URI 284 + 3. Wait 2-3 seconds for firehose indexing 285 + 4. GET /api/topics (list) → verify topic appears 286 + 5. POST /api/posts (reply to topic) → get back URI 287 + 6. Wait 2-3 seconds 288 + 7. GET /api/topics/:id → verify reply appears in thread 289 + 290 + **Mock Strategy:** 291 + - Mock `user.agent.com.atproto.repo.putRecord()` in unit tests (return fake URI/CID) 292 + - Use real database with test data for integration tests 293 + - Don't mock the database in integration tests (test real queries) 294 + 295 + ## Implementation Files 296 + 297 + **Files to modify:** 298 + - `apps/appview/src/routes/topics.ts` - Add POST handler (currently returns 501) 299 + - `apps/appview/src/routes/posts.ts` - Implement POST handler (currently returns 501) 300 + - `apps/appview/src/routes/helpers.ts` - Add validation helpers (grapheme counting, post lookup) 301 + 302 + **Files to reference:** 303 + - `apps/appview/src/middleware/auth.ts` - Use `requireAuth` from ATB-14 304 + - `packages/spike/src/index.ts` - Reference for `putRecord` usage (lines 54-73) 305 + - `packages/lexicon/lexicons/space/atbb/post.yaml` - Source of truth for record structure 306 + 307 + **Dependencies:** 308 + - `@atproto/api` - Already in dependencies (provides `UnicodeString`, `Agent`) 309 + - `@atproto/common-web` - Already in dependencies (provides `TID`) 310 + 311 + ## Open Questions 312 + 313 + None. Design is approved and ready for implementation. 314 + 315 + ## References 316 + 317 + - Linear issue: https://linear.app/atbb/issue/ATB-12 318 + - AT Proto repository spec: https://atproto.com/specs/repository 319 + - ATB-14 (OAuth): PR #14 (merged) 320 + - ATB-11 (Read endpoints): PR #13 (merged)
+6 -3
pnpm-lock.yaml
··· 41 '@skyware/jetstream': 42 specifier: ^0.2.5 43 version: 0.2.5 44 - drizzle-orm: 45 - specifier: ^0.45.1 46 - version: 0.45.1(postgres@3.4.8) 47 hono: 48 specifier: ^4.7.0 49 version: 4.11.8 ··· 54 drizzle-kit: 55 specifier: ^0.31.8 56 version: 0.31.8 57 tsx: 58 specifier: ^4.0.0 59 version: 4.21.0
··· 41 '@skyware/jetstream': 42 specifier: ^0.2.5 43 version: 0.2.5 44 hono: 45 specifier: ^4.7.0 46 version: 4.11.8 ··· 51 drizzle-kit: 52 specifier: ^0.31.8 53 version: 0.31.8 54 + drizzle-orm: 55 + specifier: ^0.45.1 56 + version: 0.45.1(postgres@3.4.8) 57 + postgres: 58 + specifier: ^3.4.8 59 + version: 3.4.8 60 tsx: 61 specifier: ^4.0.0 62 version: 4.21.0