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
at main 557 lines 21 kB view raw view rendered
1# Claude.md 2 3The role of this file is to describe common mistakes and confusion points that agents might encounter as they work in this project. If you ever encounter something in the project that surprises you, please alert the developer working with you and indicate that this is the case in the Claude.md file to help prevent future agents from having the same issue. 4 5This project is greenfield. It is okay to change the schema entirely or make what might be breaking changes. We will sort out any backfill or backwards compatibility requirements when they are actually needed. 6 7## Development 8 9### Setup 10 11```sh 12devenv shell # enter Nix dev shell (Node.js, pnpm, turbo) 13pnpm install # install all workspace dependencies 14cp .env.example .env # configure environment variables 15``` 16 17### Auto-Fixing Lint Issues 18 19Before committing, auto-fix safe lint violations: 20 21```sh 22# Fix all packages 23pnpm turbo lint:fix 24 25# Fix specific package 26pnpm --filter @atbb/appview lint:fix 27``` 28 29## Testing Standards 30 31**CRITICAL: Always run tests before committing code or requesting code review.** 32 33### Environment Variables in Tests 34 35**CRITICAL**: Turbo blocks environment variables by default for cache safety. Tests requiring env vars must declare them in `turbo.json`: 36 37```json 38{ 39 "tasks": { 40 "test": { 41 "dependsOn": ["^build"], 42 "env": ["DATABASE_URL"] 43 } 44 } 45} 46``` 47 48**Symptoms of missing declaration:** 49- Tests pass when run directly (`pnpm --filter @atbb/appview test`) 50- Tests fail when run via Turbo (`pnpm test`) with undefined env vars 51- CI fails even though env vars are set in workflow 52- Database errors like `database "username" does not exist` (postgres defaults to system username when DATABASE_URL is unset) 53 54**Why this matters:** Turbo's caching requires deterministic inputs. Environment variables that leak into tasks without declaration would make cache hits unpredictable. By explicitly declaring env vars in `turbo.json`, you tell Turbo to include them in the task's input hash and pass them through to the test process. 55 56**When adding new env vars to tests:** Update `turbo.json` immediately, or tests will mysteriously fail when run via Turbo but pass when run directly. 57 58**Placeholder tests are prohibited:** 59```typescript 60// ❌ FORBIDDEN: Stub tests provide false confidence 61it("assigns role successfully when admin has authority", async () => { 62 expect(true).toBe(true); // NOT A REAL TEST 63}); 64 65// ✅ REQUIRED: Real tests with actual assertions 66it("assigns role successfully when admin has authority", async () => { 67 const admin = await createUser(ctx, "Admin"); 68 const member = await createUser(ctx, "Member"); 69 const moderatorRole = await createRole(ctx, "Moderator", [], 20); 70 71 const res = await app.request(`/api/admin/members/${member.did}/role`, { 72 method: "POST", 73 headers: authHeaders(admin), 74 body: JSON.stringify({ roleUri: moderatorRole.uri }) 75 }); 76 77 expect(res.status).toBe(200); 78 const data = await res.json(); 79 expect(data.roleAssigned).toBe("Moderator"); 80 81 // Verify database state changed 82 const updatedMember = await getMembership(ctx, member.did); 83 expect(updatedMember.roleUri).toBe(moderatorRole.uri); 84}); 85``` 86 87**Skipped tests (`test.skip`, `it.skip`) must have a Linear issue tracking why.** Skipping without a tracked reason is prohibited. 88 89### Before Requesting Code Review 90 91**CRITICAL: Run this checklist before requesting review to catch issues early:** 92 93```sh 94# 1. Verify all tests pass 95pnpm test 96 97# 2. Check runtime dependencies are correctly placed 98# (Runtime imports must be in dependencies, not devDependencies) 99grep -r "from 'drizzle-orm'" apps/*/src # If found, verify in dependencies 100grep -r "from 'postgres'" apps/*/src # If found, verify in dependencies 101 102# 3. Verify error test coverage is comprehensive 103# For API endpoints, ensure you have tests for: 104# - Input validation (missing fields, wrong types, malformed JSON) 105# - Error classification (network→503, server→500) 106# - Error message clarity (user-friendly, no stack traces) 107``` 108 109**Common mistake:** Adding error tests AFTER review feedback instead of DURING implementation. Write error tests immediately after implementing the happy path — they often reveal bugs in error classification and input validation that are better caught before review. 110 111## Lexicon Conventions 112 113- **Source of truth is YAML** in `packages/lexicon/lexicons/`. Never edit generated JSON or TypeScript. 114- **Build pipeline:** YAML → JSON (`scripts/build.ts`) → TypeScript (`@atproto/lex-cli gen-api`). 115- **Adding a new lexicon:** Create a `.yaml` file under `lexicons/space/atbb/`, run `pnpm --filter @atbb/lexicon build`. 116- **Record keys:** Use `key: tid` for collections (multiple records per repo). Use `key: literal:self` for singletons. 117- **References:** Use `com.atproto.repo.strongRef` wrapped in named defs (e.g., `forumRef`, `subjectRef`). 118- **Extensible fields:** Use `knownValues` (not `enum`) for strings that may grow (permissions, reaction types, mod actions). 119- **Record ownership:** 120 - Forum DID owns: `forum.forum`, `forum.category`, `forum.board`, `forum.role`, `forum.theme`, `forum.themePolicy`, `modAction` 121 - User DID owns: `post`, `membership`, `reaction` 122 123## AT Protocol Conventions 124 125- **Unified post model:** There is no separate "topic" type. A `space.atbb.post` without a `reply` ref is a topic starter; one with a `reply` ref is a reply. 126- **Reply chains:** `replyRef` has both `root` (thread starter) and `parent` (direct parent) — same pattern as `app.bsky.feed.post`. 127- **MVP trust model:** The AppView holds the Forum DID's signing keys directly and writes forum-level records on behalf of admins/mods after verifying their role. This will be replaced by AT Protocol privilege delegation post-MVP. 128 129## TypeScript / Hono Gotchas 130 131- **`@types/node` is required** as a devDependency in every package that uses `process.env` or other Node APIs. `tsx` doesn't need it at runtime, but `tsc` builds will fail without it. 132- **Hono JSX `children`:** Use `PropsWithChildren<T>` from `hono/jsx` for components that accept children. Unlike React, Hono's `FC<T>` does not include `children` implicitly. 133- **HTMX attributes in JSX:** The `typed-htmx` package provides types for `hx-*` attributes. See `apps/web/src/global.d.ts` for the augmentation. 134- **Glob expansion in npm scripts:** `@atproto/lex-cli` needs file paths, not globs. Use `bash -c 'shopt -s globstar && ...'` to expand `**/*.json` in npm scripts. 135- **`.env` loading:** Dev scripts use Node's `--env-file=../../.env` flag to load the root `.env` file. No `dotenv` dependency needed. 136- **API endpoint parameter type guards:** Never trust TypeScript types for user input. Change handler parameter types from `string` to `unknown` and add explicit `typeof` checks. TypeScript types are erased at runtime — a request missing the `text` field will pass type checking but crash with `TypeError: text.trim is not a function`. 137 ```typescript 138 // ❌ BAD: Assumes text is always a string at runtime 139 export function validatePostText(text: string): { valid: boolean } { 140 const trimmed = text.trim(); // Crashes if text is undefined! 141 // ... 142 } 143 144 // ✅ GOOD: Type guard protects against runtime type mismatches 145 export function validatePostText(text: unknown): { valid: boolean } { 146 if (typeof text !== "string") { 147 return { valid: false, error: "Text is required and must be a string" }; 148 } 149 const trimmed = text.trim(); // Safe - text is proven to be a string 150 // ... 151 } 152 ``` 153- **Hono JSON parsing safety:** `await c.req.json()` throws `SyntaxError` for malformed JSON. Always wrap in try-catch and return 400 for client errors: 154 ```typescript 155 let body: any; 156 try { 157 body = await c.req.json(); 158 } catch { 159 return c.json({ error: "Invalid JSON in request body" }, 400); 160 } 161 ``` 162 163## Middleware Patterns 164 165### Middleware Composition 166 167**CRITICAL: Authentication must precede authorization checks.** 168 169When using multiple middleware functions, order matters: 170 171```typescript 172// ✅ CORRECT: requireAuth runs first, sets c.get("user") 173app.post( 174 "/api/topics", 175 requireAuth(ctx), // Step 1: Restore session, set user 176 requirePermission(ctx, "createTopics"), // Step 2: Check permission 177 async (c) => { 178 const user = c.get("user")!; // Safe - guaranteed by middleware chain 179 // ... handler logic 180 } 181); 182 183// ❌ WRONG: requirePermission runs first, user is undefined 184app.post( 185 "/api/topics", 186 requirePermission(ctx, "createTopics"), // user not set yet! 187 requireAuth(ctx), 188 async (c) => { /* ... */ } 189); 190``` 191 192**Why this matters:** `requirePermission` depends on `c.get("user")` being set by `requireAuth`. If authentication middleware doesn't run first, permission checks always fail with 401. 193 194**Testing middleware composition:** 195```typescript 196it("middleware chain executes in correct order", async () => { 197 // Verify requireAuth sets user before requirePermission checks it 198 const res = await app.request("/api/topics", { 199 method: "POST", 200 headers: { Cookie: "atbb_session=valid_token" } 201 }); 202 203 // Should succeed if both middlewares run in order 204 expect(res.status).not.toBe(401); 205}); 206``` 207 208## Error Handling Standards 209 210Follow these patterns for robust, debuggable production code: 211 212### API Route Handlers 213 214**Required for all database-backed endpoints:** 2151. Validate input parameters before database queries (return 400 for invalid input) 2162. Wrap database queries in try-catch with structured logging 2173. Check resource existence explicitly (return 404 for missing resources) 2184. Return proper HTTP status codes (400/404/500, not always 500) 219 220**Example pattern:** 221```typescript 222export function createForumRoutes(ctx: AppContext) { 223 return new Hono().get("/", async (c) => { 224 try { 225 const [forum] = await ctx.db 226 .select() 227 .from(forums) 228 .where(eq(forums.rkey, "self")) 229 .limit(1); 230 231 if (!forum) { 232 return c.json({ error: "Forum not found" }, 404); 233 } 234 235 return c.json({ /* success response */ }); 236 } catch (error) { 237 console.error("Failed to query forum metadata", { 238 operation: "GET /api/forum", 239 error: error instanceof Error ? error.message : String(error), 240 }); 241 return c.json( 242 { error: "Failed to retrieve forum metadata. Please try again later." }, 243 500 244 ); 245 } 246 }); 247} 248``` 249 250### Catch Block Guidelines 251 252**DO:** 253- Catch specific error types when possible (`instanceof RangeError`, `instanceof SyntaxError`) 254- Re-throw unexpected errors (don't swallow programming bugs like `TypeError`) 255- Log with structured context: operation name, relevant IDs, error message 256- Return user-friendly messages (no stack traces in production) 257- Classify errors by user action (400, 503) vs server bugs (500) 258 259**DON'T:** 260- Use bare `catch` blocks that hide all error types 261- Return generic "try again later" for client errors (400) vs server errors (500) 262- Fabricate data in catch blocks (return null or fail explicitly) 263- Use empty catch blocks or catch without logging 264- Put two distinct operations in the same try block when they have different failure semantics — a failure in the second operation will report as failure of the first 265 266**Try Block Granularity:** 267 268When a try block covers multiple distinct operations in sequence, errors from later steps get reported with the wrong context. Split into separate try blocks when operations have meaningfully different failure messages: 269 270```typescript 271// ❌ BAD: DB re-query failure reports "Failed to create category" even though 272// the PDS write already succeeded — misleading for operators debugging 273try { 274 const result = await createCategory(...); // PDS write succeeded 275 categoryUri = result.uri; 276 const [cat] = await db.select()...; // DB re-query fails here 277} catch (error) { 278 consola.error("Failed to create category:", ...); // Inaccurate! 279} 280 281// ✅ GOOD: each operation has its own try block and specific error message 282try { 283 const result = await createCategory(...); 284 categoryUri = result.uri; 285} catch (error) { 286 consola.error("Failed to create category:", ...); 287} 288 289try { 290 const [cat] = await db.select()...; 291} catch (error) { 292 consola.error("Failed to look up category ID after creation:", ...); 293} 294``` 295 296**Programming Error Re-Throwing Pattern:** 297 298```typescript 299// ✅ CORRECT: Re-throw programming errors, catch runtime errors 300try { 301 const result = await ctx.db.select()...; 302 return processResult(result); 303} catch (error) { 304 // Re-throw programming errors (code bugs) - don't hide them 305 if (error instanceof TypeError || 306 error instanceof ReferenceError || 307 error instanceof SyntaxError) { 308 console.error("CRITICAL: Programming error detected", { 309 error: error.message, 310 stack: error.stack, 311 operation: "checkPermission" 312 }); 313 throw error; // Let global error handler catch it 314 } 315 316 // Log and handle expected runtime errors (DB failures, network issues) 317 console.error("Database query failed", { 318 operation: "checkPermission", 319 error: error instanceof Error ? error.message : String(error) 320 }); 321 return null; // Fail safely for business logic 322} 323``` 324 325**Why re-throw programming errors:** 326- `TypeError` = code bug (e.g., `role.permisions.includes()` typo) 327- `ReferenceError` = code bug (e.g., using undefined variable) 328- `SyntaxError` = code bug (e.g., malformed JSON.parse in your code) 329- These should crash during development, not be silently logged 330- Catching them hides bugs and makes debugging impossible 331 332**Error Classification Helper:** 333 334Create helper functions to classify errors consistently: 335 336```typescript 337// File: src/lib/errors.ts 338export function isProgrammingError(error: unknown): boolean { 339 return error instanceof TypeError || 340 error instanceof ReferenceError || 341 error instanceof SyntaxError; 342} 343 344export function isNetworkError(error: unknown): boolean { 345 if (!(error instanceof Error)) return false; 346 const msg = error.message.toLowerCase(); 347 return msg.includes("fetch failed") || 348 msg.includes("econnrefused") || 349 msg.includes("enotfound") || 350 msg.includes("timeout"); 351} 352 353// Usage in route handlers: 354} catch (error) { 355 if (isProgrammingError(error)) { 356 throw error; // Don't catch programming bugs 357 } 358 359 if (isNetworkError(error)) { 360 return c.json({ 361 error: "Unable to reach external service. Please try again later." 362 }, 503); // User should retry 363 } 364 365 return c.json({ 366 error: "An unexpected error occurred. Please contact support." 367 }, 500); // Server bug, needs investigation 368} 369``` 370 371### Helper Functions 372 373**Validation helpers should:** 374- Return `null` for invalid input (not throw) 375- Re-throw unexpected errors 376- Use specific error type checking 377 378**Example:** 379```typescript 380export function parseBigIntParam(value: string): bigint | null { 381 try { 382 return BigInt(value); 383 } catch (error) { 384 if (error instanceof RangeError || error instanceof SyntaxError) { 385 return null; // Expected error for invalid input 386 } 387 throw error; // Unexpected error - let it bubble up 388 } 389} 390``` 391 392**Serialization helpers should:** 393- Avoid silent fallbacks (log warnings if fabricating data) 394- Prefer returning `null` over fake values (`"0"`, `new Date()`) 395- Document fallback behavior in JSDoc if unavoidable 396 397## Security-Critical Code Standards 398 399When implementing authentication, authorization, or permission systems, follow these additional requirements: 400 401### 1. Fail-Closed Security 402 403**Security checks must deny access by default when encountering errors.** 404 405```typescript 406// ✅ CORRECT: Fail closed - deny access on any error 407export async function checkPermission( 408 ctx: AppContext, 409 did: string, 410 permission: string 411): Promise<boolean> { 412 try { 413 const [membership] = await ctx.db.select()...; 414 if (!membership || !membership.roleUri) { 415 return false; // No membership = deny access 416 } 417 418 const [role] = await ctx.db.select()...; 419 if (!role) { 420 return false; // Role deleted = deny access 421 } 422 423 // Permissions live in role_permissions join table, not role.permissions array 424 const [match] = await ctx.db 425 .select() 426 .from(rolePermissions) 427 .where(and( 428 eq(rolePermissions.roleId, role.id), 429 or( 430 eq(rolePermissions.permission, permission), 431 eq(rolePermissions.permission, "*") 432 ) 433 )) 434 .limit(1); 435 436 return !!match; 437 } catch (error) { 438 if (isProgrammingError(error)) throw error; 439 440 console.error("Failed to check permissions - denying access", { 441 did, permission, error: String(error) 442 }); 443 return false; // Error = deny access (fail closed) 444 } 445} 446 447// ❌ WRONG: Fail open - grants access on error 448} catch (error) { 449 console.error("Permission check failed"); 450 return true; // SECURITY BUG: grants access on DB error! 451} 452``` 453 454**Test fail-closed behavior:** 455```typescript 456it("denies access when database query fails (fail closed)", async () => { 457 vi.spyOn(ctx.db, "select").mockRejectedValueOnce(new Error("DB connection lost")); 458 459 const result = await checkPermission(ctx, "did:plc:user", "createTopics"); 460 461 expect(result).toBe(false); // Prove fail-closed behavior 462 expect(console.error).toHaveBeenCalledWith( 463 expect.stringContaining("denying access"), 464 expect.any(Object) 465 ); 466}); 467``` 468 469### 2. Startup Failures for Missing Security Infrastructure 470 471**Security-critical infrastructure must fail fast on startup, not at first request.** 472 473```typescript 474// ✅ CORRECT: Throw error on startup if ForumAgent unavailable 475export async function seedDefaultRoles(ctx: AppContext) { 476 const agent = ctx.forumAgent; 477 if (!agent) { 478 console.error("CRITICAL: ForumAgent not available - role system non-functional", { 479 operation: "seedDefaultRoles", 480 forumDid: ctx.config.forumDid 481 }); 482 throw new Error( 483 "Cannot seed roles without ForumAgent - permission system would be broken" 484 ); 485 } 486 // ... seeding logic 487} 488 489// ❌ WRONG: Silent failure allows server to start without roles 490if (!agent) { 491 console.warn("ForumAgent not available, skipping role seeding"); 492 return { created: 0, skipped: 0 }; // Server starts but is broken! 493} 494``` 495 496**Why this matters:** If the permission system is broken, every request will fail authorization. It's better to fail startup loudly than silently deploy a non-functional system. 497 498## Documentation & Project Tracking 499 500**Keep these synchronized when completing work:** 501 5021. **`docs/atproto-forum-plan.md`** — Master project plan with phase checklist 503 - Mark items complete `[x]` when implementation is done and tested 504 - Add brief status notes with file references and Linear issue IDs 505 - Update immediately after completing milestones 506 5072. **Linear issues** — Task tracker at https://linear.app/atbb 508 - Update status: Backlog → In Progress → Done 509 - Add comments documenting implementation details when marking Done 510 - Keep status in sync with actual codebase state, not planning estimates 511 5123. **`docs/plans/` convention:** 513 - Active/in-progress plan documents go directly in `docs/plans/` 514 - Completed plan documents move to `docs/plans/complete/` when work is shipped 515 5164. **Workflow:** When finishing a task: 517 ```sh 518 # 1. Run tests to verify implementation is correct 519 pnpm test 520 521 # 2. If tests pass, commit your changes 522 git add . 523 git commit -m "feat: your changes" 524 525 # 3. Update plan document: mark [x] and add completion note 526 # 4. Update Linear: change status to Done, add implementation comment 527 # 5. Push and request code review 528 # 6. After review approval: include "docs:" prefix when committing plan updates 529 ``` 530 531**Why this matters:** The plan document and Linear can drift from reality as code evolves. Regular synchronization prevents rediscovering completed work and ensures accurate project status. 532 533## Bruno API Collections 534 535**CRITICAL: Update Bruno collections in the SAME commit as route implementation.** 536 537The `bruno/` directory contains [Bruno](https://www.usebruno.com/) collections for API documentation and interactive testing. See `CONTRIBUTING.md` for the full update workflow and file template. 538 539**Common Mistakes:** 540 541**DON'T:** 542- Commit API changes without updating Bruno collections 543- Use hardcoded URLs instead of environment variables (`{{appview_url}}`) 544- Skip documenting error cases (only document 200 responses) 545- Leave placeholder/example data that doesn't match actual API behavior 546- Forget to update assertions when response format changes 547 548**DO:** 549- Update Bruno files in the same commit as route implementation 550- Use environment variables for all URLs and test data 551- Document all HTTP status codes the endpoint can return 552 553## Git Conventions 554 555- Do not include `Co-Authored-By` lines in commit messages. 556- `prior-art/` contains git submodules (Rust AppView, original lexicons, delegation spec) — reference material only, not used at build time. 557- Worktrees with submodules need `submodule deinit --all -f` then `worktree remove --force` to clean up.