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