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## Theme Resolution & Cookie Protocol
185
186<!-- freshness: 2026-03-20 -->
187
188The web app resolves which theme to render via a 5-step waterfall in `apps/web/src/lib/theme-resolution.ts`:
189
1901. **Detect color scheme** -- `atbb-color-scheme` cookie or `Sec-CH-Prefers-Color-Scheme` hint (default: `light`)
1912. **Fetch theme policy** -- `GET /api/theme-policy` from AppView (cached in-memory with TTL)
1923. **User preference** -- `atbb-light-theme` / `atbb-dark-theme` cookie (validated against `policy.availableThemes`; ignored when `policy.allowUserChoice` is false)
1934. **Forum default** -- `defaultLightThemeUri` / `defaultDarkThemeUri` from policy
1945. **Hardcoded fallback** -- neobrutal-light or neobrutal-dark bundled presets
195
196**Cookie contracts:**
197
198| Cookie | Written by | Contains | Max-Age |
199|--------|-----------|----------|---------|
200| `atbb-color-scheme` | Client-side JS (toggle button) | `light` or `dark` | 1 year |
201| `atbb-light-theme` | `POST /settings/appearance` | AT URI of chosen light theme | 1 year |
202| `atbb-dark-theme` | `POST /settings/appearance` | AT URI of chosen dark theme | 1 year |
203
204**Invariants:**
205- User preference cookies are always validated against the current `policy.availableThemes` on read. Stale or removed theme URIs silently fall through to the forum default.
206- `resolveTheme()` never throws -- it always returns a usable `ResolvedTheme`.
207- Settings routes (`/settings`, `/settings/appearance`) require authentication. The preview endpoint (`/settings/preview`) is unauthenticated — it returns only public theme data (color swatches). The preference form is only rendered when `policy.allowUserChoice` is true.
208- **Settings routes bypass ThemeCache intentionally.** `GET /settings` and `POST /settings/appearance` call `fetch()` directly (not via `resolveTheme()`), so they always get fresh policy data. This is by design: preference saves must validate against the current policy, not a potentially stale cached copy.
209
210## Middleware Patterns
211
212### Middleware Composition
213
214**CRITICAL: Authentication must precede authorization checks.**
215
216When using multiple middleware functions, order matters:
217
218```typescript
219// ✅ CORRECT: requireAuth runs first, sets c.get("user")
220app.post(
221 "/api/topics",
222 requireAuth(ctx), // Step 1: Restore session, set user
223 requirePermission(ctx, "createTopics"), // Step 2: Check permission
224 async (c) => {
225 const user = c.get("user")!; // Safe - guaranteed by middleware chain
226 // ... handler logic
227 }
228);
229
230// ❌ WRONG: requirePermission runs first, user is undefined
231app.post(
232 "/api/topics",
233 requirePermission(ctx, "createTopics"), // user not set yet!
234 requireAuth(ctx),
235 async (c) => { /* ... */ }
236);
237```
238
239**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.
240
241**Testing middleware composition:**
242```typescript
243it("middleware chain executes in correct order", async () => {
244 // Verify requireAuth sets user before requirePermission checks it
245 const res = await app.request("/api/topics", {
246 method: "POST",
247 headers: { Cookie: "atbb_session=valid_token" }
248 });
249
250 // Should succeed if both middlewares run in order
251 expect(res.status).not.toBe(401);
252});
253```
254
255## Error Handling Standards
256
257Follow these patterns for robust, debuggable production code:
258
259### API Route Handlers
260
261**Required for all database-backed endpoints:**
2621. Validate input parameters before database queries (return 400 for invalid input)
2632. Wrap database queries in try-catch with structured logging
2643. Check resource existence explicitly (return 404 for missing resources)
2654. Return proper HTTP status codes (400/404/500, not always 500)
266
267**Example pattern:**
268```typescript
269export function createForumRoutes(ctx: AppContext) {
270 return new Hono().get("/", async (c) => {
271 try {
272 const [forum] = await ctx.db
273 .select()
274 .from(forums)
275 .where(eq(forums.rkey, "self"))
276 .limit(1);
277
278 if (!forum) {
279 return c.json({ error: "Forum not found" }, 404);
280 }
281
282 return c.json({ /* success response */ });
283 } catch (error) {
284 console.error("Failed to query forum metadata", {
285 operation: "GET /api/forum",
286 error: error instanceof Error ? error.message : String(error),
287 });
288 return c.json(
289 { error: "Failed to retrieve forum metadata. Please try again later." },
290 500
291 );
292 }
293 });
294}
295```
296
297### Catch Block Guidelines
298
299**DO:**
300- Catch specific error types when possible (`instanceof RangeError`, `instanceof SyntaxError`)
301- Re-throw unexpected errors (don't swallow programming bugs like `TypeError`)
302- Log with structured context: operation name, relevant IDs, error message
303- Return user-friendly messages (no stack traces in production)
304- Classify errors by user action (400, 503) vs server bugs (500)
305
306**DON'T:**
307- Use bare `catch` blocks that hide all error types
308- Return generic "try again later" for client errors (400) vs server errors (500)
309- Fabricate data in catch blocks (return null or fail explicitly)
310- Use empty catch blocks or catch without logging
311- 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
312
313**Try Block Granularity:**
314
315When 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:
316
317```typescript
318// ❌ BAD: DB re-query failure reports "Failed to create category" even though
319// the PDS write already succeeded — misleading for operators debugging
320try {
321 const result = await createCategory(...); // PDS write succeeded
322 categoryUri = result.uri;
323 const [cat] = await db.select()...; // DB re-query fails here
324} catch (error) {
325 consola.error("Failed to create category:", ...); // Inaccurate!
326}
327
328// ✅ GOOD: each operation has its own try block and specific error message
329try {
330 const result = await createCategory(...);
331 categoryUri = result.uri;
332} catch (error) {
333 consola.error("Failed to create category:", ...);
334}
335
336try {
337 const [cat] = await db.select()...;
338} catch (error) {
339 consola.error("Failed to look up category ID after creation:", ...);
340}
341```
342
343**Programming Error Re-Throwing Pattern:**
344
345```typescript
346// ✅ CORRECT: Re-throw programming errors, catch runtime errors
347try {
348 const result = await ctx.db.select()...;
349 return processResult(result);
350} catch (error) {
351 // Re-throw programming errors (code bugs) - don't hide them
352 if (error instanceof TypeError ||
353 error instanceof ReferenceError ||
354 error instanceof SyntaxError) {
355 console.error("CRITICAL: Programming error detected", {
356 error: error.message,
357 stack: error.stack,
358 operation: "checkPermission"
359 });
360 throw error; // Let global error handler catch it
361 }
362
363 // Log and handle expected runtime errors (DB failures, network issues)
364 console.error("Database query failed", {
365 operation: "checkPermission",
366 error: error instanceof Error ? error.message : String(error)
367 });
368 return null; // Fail safely for business logic
369}
370```
371
372**Why re-throw programming errors:**
373- `TypeError` = code bug (e.g., `role.permisions.includes()` typo)
374- `ReferenceError` = code bug (e.g., using undefined variable)
375- `SyntaxError` = code bug (e.g., malformed JSON.parse in your code)
376- These should crash during development, not be silently logged
377- Catching them hides bugs and makes debugging impossible
378
379**Error Classification Helper:**
380
381Create helper functions to classify errors consistently:
382
383```typescript
384// File: src/lib/errors.ts
385export function isProgrammingError(error: unknown): boolean {
386 return error instanceof TypeError ||
387 error instanceof ReferenceError ||
388 error instanceof SyntaxError;
389}
390
391export function isNetworkError(error: unknown): boolean {
392 if (!(error instanceof Error)) return false;
393 const msg = error.message.toLowerCase();
394 return msg.includes("fetch failed") ||
395 msg.includes("econnrefused") ||
396 msg.includes("enotfound") ||
397 msg.includes("timeout");
398}
399
400// Usage in route handlers:
401} catch (error) {
402 if (isProgrammingError(error)) {
403 throw error; // Don't catch programming bugs
404 }
405
406 if (isNetworkError(error)) {
407 return c.json({
408 error: "Unable to reach external service. Please try again later."
409 }, 503); // User should retry
410 }
411
412 return c.json({
413 error: "An unexpected error occurred. Please contact support."
414 }, 500); // Server bug, needs investigation
415}
416```
417
418### Helper Functions
419
420**Validation helpers should:**
421- Return `null` for invalid input (not throw)
422- Re-throw unexpected errors
423- Use specific error type checking
424
425**Example:**
426```typescript
427export function parseBigIntParam(value: string): bigint | null {
428 try {
429 return BigInt(value);
430 } catch (error) {
431 if (error instanceof RangeError || error instanceof SyntaxError) {
432 return null; // Expected error for invalid input
433 }
434 throw error; // Unexpected error - let it bubble up
435 }
436}
437```
438
439**Serialization helpers should:**
440- Avoid silent fallbacks (log warnings if fabricating data)
441- Prefer returning `null` over fake values (`"0"`, `new Date()`)
442- Document fallback behavior in JSDoc if unavoidable
443
444## Security-Critical Code Standards
445
446When implementing authentication, authorization, or permission systems, follow these additional requirements:
447
448### 1. Fail-Closed Security
449
450**Security checks must deny access by default when encountering errors.**
451
452```typescript
453// ✅ CORRECT: Fail closed - deny access on any error
454export async function checkPermission(
455 ctx: AppContext,
456 did: string,
457 permission: string
458): Promise<boolean> {
459 try {
460 const [membership] = await ctx.db.select()...;
461 if (!membership || !membership.roleUri) {
462 return false; // No membership = deny access
463 }
464
465 const [role] = await ctx.db.select()...;
466 if (!role) {
467 return false; // Role deleted = deny access
468 }
469
470 // Permissions live in role_permissions join table, not role.permissions array
471 const [match] = await ctx.db
472 .select()
473 .from(rolePermissions)
474 .where(and(
475 eq(rolePermissions.roleId, role.id),
476 or(
477 eq(rolePermissions.permission, permission),
478 eq(rolePermissions.permission, "*")
479 )
480 ))
481 .limit(1);
482
483 return !!match;
484 } catch (error) {
485 if (isProgrammingError(error)) throw error;
486
487 console.error("Failed to check permissions - denying access", {
488 did, permission, error: String(error)
489 });
490 return false; // Error = deny access (fail closed)
491 }
492}
493
494// ❌ WRONG: Fail open - grants access on error
495} catch (error) {
496 console.error("Permission check failed");
497 return true; // SECURITY BUG: grants access on DB error!
498}
499```
500
501**Test fail-closed behavior:**
502```typescript
503it("denies access when database query fails (fail closed)", async () => {
504 vi.spyOn(ctx.db, "select").mockRejectedValueOnce(new Error("DB connection lost"));
505
506 const result = await checkPermission(ctx, "did:plc:user", "createTopics");
507
508 expect(result).toBe(false); // Prove fail-closed behavior
509 expect(console.error).toHaveBeenCalledWith(
510 expect.stringContaining("denying access"),
511 expect.any(Object)
512 );
513});
514```
515
516### 2. Startup Failures for Missing Security Infrastructure
517
518**Security-critical infrastructure must fail fast on startup, not at first request.**
519
520```typescript
521// ✅ CORRECT: Throw error on startup if ForumAgent unavailable
522export async function seedDefaultRoles(ctx: AppContext) {
523 const agent = ctx.forumAgent;
524 if (!agent) {
525 console.error("CRITICAL: ForumAgent not available - role system non-functional", {
526 operation: "seedDefaultRoles",
527 forumDid: ctx.config.forumDid
528 });
529 throw new Error(
530 "Cannot seed roles without ForumAgent - permission system would be broken"
531 );
532 }
533 // ... seeding logic
534}
535
536// ❌ WRONG: Silent failure allows server to start without roles
537if (!agent) {
538 console.warn("ForumAgent not available, skipping role seeding");
539 return { created: 0, skipped: 0 }; // Server starts but is broken!
540}
541```
542
543**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.
544
545## Documentation & Project Tracking
546
547**Keep these synchronized when completing work:**
548
5491. **`docs/atproto-forum-plan.md`** — Master project plan with phase checklist
550 - Mark items complete `[x]` when implementation is done and tested
551 - Add brief status notes with file references and Linear issue IDs
552 - Update immediately after completing milestones
553
5542. **Linear issues** — Task tracker at https://linear.app/atbb
555 - Update status: Backlog → In Progress → Done
556 - Add comments documenting implementation details when marking Done
557 - Keep status in sync with actual codebase state, not planning estimates
558
5593. **`docs/plans/` convention:**
560 - Active/in-progress plan documents go directly in `docs/plans/`
561 - Completed plan documents move to `docs/plans/complete/` when work is shipped
562
5634. **Workflow:** When finishing a task:
564 ```sh
565 # 1. Run tests to verify implementation is correct
566 pnpm test
567
568 # 2. If tests pass, commit your changes
569 git add .
570 git commit -m "feat: your changes"
571
572 # 3. Update plan document: mark [x] and add completion note
573 # 4. Update Linear: change status to Done, add implementation comment
574 # 5. Push and request code review
575 # 6. After review approval: include "docs:" prefix when committing plan updates
576 ```
577
578**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.
579
580## Bruno API Collections
581
582**CRITICAL: Update Bruno collections in the SAME commit as route implementation.**
583
584The `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.
585
586**Common Mistakes:**
587
588**DON'T:**
589- Commit API changes without updating Bruno collections
590- Use hardcoded URLs instead of environment variables (`{{appview_url}}`)
591- Skip documenting error cases (only document 200 responses)
592- Leave placeholder/example data that doesn't match actual API behavior
593- Forget to update assertions when response format changes
594
595**DO:**
596- Update Bruno files in the same commit as route implementation
597- Use environment variables for all URLs and test data
598- Document all HTTP status codes the endpoint can return
599
600## Git Conventions
601
602- Do not include `Co-Authored-By` lines in commit messages.
603- `prior-art/` contains git submodules (Rust AppView, original lexicons, delegation spec) — reference material only, not used at build time.
604- Worktrees with submodules need `submodule deinit --all -f` then `worktree remove --force` to clean up.