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

docs: add design document for ATB-15 membership auto-creation

- Documents fire-and-forget architecture with graceful degradation
- Specifies helper function createMembershipForUser() implementation
- Details OAuth callback integration point after profile fetch
- Outlines comprehensive testing strategy (unit, integration, error scenarios)
- Covers edge cases: race conditions, firehose lag, PDS failures
- Includes implementation checklist and future considerations

+365
+365
docs/plans/2026-02-11-membership-auto-creation-design.md
··· 1 + # Auto-Create Membership Records on Login (ATB-15) 2 + 3 + **Date:** 2026-02-11 4 + **Author:** Claude Code 5 + **Linear Issue:** [ATB-15](https://linear.app/atbb/issue/ATB-15/auto-create-membership-on-first-login) 6 + **Status:** Approved, Ready for Implementation 7 + 8 + --- 9 + 10 + ## Overview 11 + 12 + When a user completes OAuth login for the first time, the AppView will automatically create a `space.atbb.membership` record on the user's PDS. This establishes their membership in the forum with default permissions. The firehose/indexer pipeline will asynchronously index this record into the `memberships` table. 13 + 14 + ## Architecture & Integration Point 15 + 16 + ### Where the Feature Fits 17 + 18 + The membership creation integrates into the OAuth callback flow in `apps/appview/src/routes/auth.ts`. The flow is: 19 + 20 + 1. User completes OAuth at their PDS 21 + 2. PDS redirects to `/api/auth/callback?code=...&state=...` 22 + 3. AppView exchanges code for tokens (line 129) 23 + 4. AppView fetches user profile to get handle (lines 132-153) 24 + 5. **✨ NEW: AppView attempts to create membership record on user's PDS** 25 + 6. AppView creates cookie session (line 167) 26 + 7. AppView redirects to homepage (line 184) 27 + 28 + ### Key Design Decision: Fire-and-Forget with Graceful Degradation 29 + 30 + The membership creation is **best-effort** during login: 31 + 32 + - ✅ **Success** → User has membership record, firehose will index it 33 + - ❌ **Failure** → Login still succeeds, user can participate immediately 34 + - 🔄 **Retry** → Can be retried on next login (duplicate check prevents issues) 35 + 36 + This follows the "eventual consistency" pattern already used in the codebase for PDS writes (topics, posts). The indexer will eventually pick up the membership record from the firehose, but the user can use the forum before that happens. 37 + 38 + **Why this works:** 39 + 40 + - Authentication state (OAuth session) is independent of membership state 41 + - The AppView doesn't require indexed membership for basic forum access in MVP 42 + - Firehose subscription is reliable for eventual indexing 43 + - Repeated logins won't create duplicates (we check the database first) 44 + 45 + ## Implementation Details 46 + 47 + ### Helper Function: `createMembershipForUser()` 48 + 49 + **New file:** `apps/appview/src/lib/membership.ts` 50 + 51 + **Function signature:** 52 + 53 + ```typescript 54 + async function createMembershipForUser( 55 + ctx: AppContext, 56 + agent: Agent, 57 + did: string 58 + ): Promise<{ created: boolean; uri?: string; cid?: string }> 59 + ``` 60 + 61 + **Internal flow:** 62 + 63 + #### 1. Check for Existing Membership (Prevent Duplicates) 64 + 65 + ```typescript 66 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 67 + 68 + const existing = await ctx.db 69 + .select() 70 + .from(memberships) 71 + .where(and( 72 + eq(memberships.did, did), 73 + eq(memberships.forumUri, forumUri) 74 + )) 75 + .limit(1); 76 + 77 + if (existing.length > 0) { 78 + return { created: false }; // Already a member 79 + } 80 + ``` 81 + 82 + **Why check the database instead of PDS?** 83 + 84 + - Database query is fast (indexed lookup) 85 + - PDS query would require listing all membership records for the user 86 + - Database represents indexed state, which is the source of truth for the AppView 87 + 88 + #### 2. Fetch Forum Metadata (Need URI and CID for strongRef) 89 + 90 + ```typescript 91 + const [forum] = await ctx.db 92 + .select() 93 + .from(forums) 94 + .where(eq(forums.rkey, 'self')) 95 + .limit(1); 96 + 97 + if (!forum) { 98 + throw new Error("Forum not found"); // Should never happen in production 99 + } 100 + 101 + const forumUri = `at://${forum.did}/space.atbb.forum.forum/${forum.rkey}`; 102 + ``` 103 + 104 + #### 3. Write Membership Record to User's PDS 105 + 106 + ```typescript 107 + const rkey = TID.nextStr(); 108 + const now = new Date().toISOString(); 109 + 110 + const result = await agent.com.atproto.repo.putRecord({ 111 + repo: did, 112 + collection: "space.atbb.membership", 113 + rkey, 114 + record: { 115 + $type: "space.atbb.membership", 116 + forum: { 117 + forum: { uri: forumUri, cid: forum.cid } 118 + }, 119 + createdAt: now, 120 + joinedAt: now, 121 + // role field omitted - defaults to guest/member permissions 122 + } 123 + }); 124 + 125 + return { created: true, uri: result.data.uri, cid: result.data.cid }; 126 + ``` 127 + 128 + **Lexicon compliance:** 129 + 130 + - `forum` (required): strongRef to forum record 131 + - `createdAt` (required): ISO-8601 timestamp 132 + - `joinedAt` (optional but included): ISO-8601 timestamp 133 + - `role` (optional, omitted): Will be added in ATB-17 (role assignment UI) 134 + 135 + ### Error Handling Strategy 136 + 137 + The helper function **throws on errors** (database failures, PDS write failures). The **caller** (OAuth callback) catches and logs these errors but allows login to proceed. 138 + 139 + This separates concerns: 140 + 141 + - **Helper** = pure logic, throws on failure 142 + - **Caller** = resilience policy, decides whether to fail the overall operation 143 + 144 + ### OAuth Callback Integration 145 + 146 + **Modification to `apps/appview/src/routes/auth.ts` callback handler:** 147 + 148 + Insert after line 160 (after profile fetch, before cookie session creation): 149 + 150 + ```typescript 151 + // After successful profile fetch 152 + console.log(JSON.stringify({ 153 + event: "oauth.callback.success", 154 + did: session.did, 155 + handle, 156 + timestamp: new Date().toISOString(), 157 + })); 158 + 159 + // ✨ NEW: Attempt to create membership record 160 + try { 161 + const agent = new Agent(session); 162 + const result = await createMembershipForUser(ctx, agent, session.did); 163 + 164 + if (result.created) { 165 + console.log("Membership record created", { 166 + did: session.did, 167 + uri: result.uri, 168 + operation: "oauth.callback.membership.created" 169 + }); 170 + } else { 171 + console.log("Membership already exists", { 172 + did: session.did, 173 + operation: "oauth.callback.membership.exists" 174 + }); 175 + } 176 + } catch (error) { 177 + // CRITICAL: Don't fail login if membership creation fails 178 + console.warn("Failed to create membership record - login will proceed", { 179 + did: session.did, 180 + operation: "oauth.callback.membership.failed", 181 + error: error instanceof Error ? error.message : String(error), 182 + }); 183 + // Continue with login flow 184 + } 185 + 186 + // Continue with cookie session creation... 187 + const cookieToken = randomBytes(32).toString("base64url"); 188 + ``` 189 + 190 + **Key points:** 191 + 192 + 1. **Non-blocking**: Wrapped in try-catch, errors are logged as warnings 193 + 2. **Structured logging**: Three outcomes tracked (created, exists, failed) 194 + 3. **Agent creation**: Create fresh Agent from session (same pattern as POST endpoints) 195 + 4. **Fire-and-forget**: No UI feedback about membership creation - it's transparent 196 + 197 + **Why log as `console.warn` for failures?** 198 + 199 + This distinguishes between: 200 + 201 + - `console.log` → Expected outcomes (created, already exists) 202 + - `console.warn` → Unexpected but handled (PDS unreachable, database error) 203 + - `console.error` → Unhandled failures that break the operation 204 + 205 + Operators can monitor warning logs to detect persistent membership creation issues without being alarmed by expected behavior. 206 + 207 + ## Testing Strategy 208 + 209 + **Test file:** `apps/appview/src/lib/__tests__/membership.test.ts` 210 + 211 + Following the comprehensive testing patterns established in ATB-12 (topics/posts), we need three layers of tests. 212 + 213 + ### 1. Unit Tests for `createMembershipForUser()` Helper 214 + 215 + ```typescript 216 + describe("createMembershipForUser", () => { 217 + it("creates membership record when none exists", async () => { 218 + // Mock: empty database, successful PDS write 219 + // Verify: putRecord called with correct lexicon structure 220 + // Assert: returns { created: true, uri, cid } 221 + }); 222 + 223 + it("returns early when membership already exists", async () => { 224 + // Mock: existing membership in database 225 + // Verify: putRecord NOT called (no duplicate write) 226 + // Assert: returns { created: false } 227 + }); 228 + 229 + it("throws when forum metadata not found", async () => { 230 + // Mock: empty forums table 231 + // Assert: throws error with "Forum not found" 232 + }); 233 + 234 + it("throws when PDS write fails", async () => { 235 + // Mock: network error from putRecord 236 + // Assert: error bubbles up (not caught by helper) 237 + }); 238 + 239 + it("checks for duplicates using DID + forumUri", async () => { 240 + // Verify: database query uses AND condition 241 + // Edge case: same user, different forums = separate memberships 242 + }); 243 + }); 244 + ``` 245 + 246 + ### 2. Integration Tests for OAuth Callback 247 + 248 + ```typescript 249 + describe("POST /api/auth/callback with membership creation", () => { 250 + it("creates membership on first successful login", async () => { 251 + // Mock: OAuth callback params, empty memberships table 252 + // Verify: membership creation called, login succeeds 253 + // Assert: response redirects to /, session cookie set 254 + }); 255 + 256 + it("does not create duplicate on repeated login", async () => { 257 + // Mock: existing membership in database 258 + // Verify: putRecord not called on second login 259 + // Assert: login still succeeds 260 + }); 261 + 262 + it("allows login to succeed even if membership creation fails", async () => { 263 + // Mock: PDS write throws error 264 + // Verify: warning logged, session still created 265 + // Assert: response redirects to /, session cookie set 266 + // CRITICAL: This is the "graceful degradation" requirement 267 + }); 268 + }); 269 + ``` 270 + 271 + ### 3. Error Scenario Tests 272 + 273 + ```typescript 274 + describe("Membership creation error handling", () => { 275 + it("handles database connection failure gracefully", async () => { 276 + // Mock: db.select() throws connection error 277 + // Assert: login succeeds, warning logged 278 + }); 279 + 280 + it("handles PDS network timeout gracefully", async () => { 281 + // Mock: putRecord times out 282 + // Assert: login succeeds, warning logged 283 + }); 284 + 285 + it("handles invalid PDS response gracefully", async () => { 286 + // Mock: putRecord returns malformed data 287 + // Assert: login succeeds, warning logged 288 + }); 289 + }); 290 + ``` 291 + 292 + **Test count estimate:** 12-15 tests total (5 unit + 3 integration + 4-7 error scenarios) 293 + 294 + **Why this testing approach?** 295 + 296 + From the memory notes on ATB-12, we learned that error tests should be written **during implementation**, not after review feedback. This prevents three review cycles and catches issues like: 297 + 298 + - Type guards missing 299 + - Error classification wrong (should login fail or succeed?) 300 + - Silent data fabrication 301 + 302 + ## Edge Cases 303 + 304 + ### 1. Race Condition on Repeated Logins 305 + 306 + **Scenario:** User opens two browser tabs, both initiate login simultaneously 307 + 308 + **Handling:** Database query uses `UNIQUE(did, forumUri)` constraint - second write will fail indexing but won't crash 309 + 310 + **Result:** One membership record exists, both logins succeed 311 + 312 + ### 2. Firehose Lag 313 + 314 + **Scenario:** Membership created on PDS but not yet indexed 315 + 316 + **Handling:** Duplicate check uses database (indexed state), not PDS 317 + 318 + **Result:** On next login, database shows no membership → second attempt → PDS rejects duplicate rkey 319 + 320 + **Mitigation:** TID-based rkeys make collisions astronomically unlikely 321 + 322 + ### 3. User Has No PDS (Edge Case in OAuth Spec) 323 + 324 + **Scenario:** OAuth succeeds but user's PDS becomes unreachable 325 + 326 + **Handling:** Agent will throw on putRecord → caught by callback → login succeeds 327 + 328 + **Result:** User can browse forum, membership retried on next login 329 + 330 + ### 4. Forum Metadata Missing 331 + 332 + **Scenario:** `forums` table is empty (fresh deployment) 333 + 334 + **Handling:** Helper throws "Forum not found" → caught by callback → login succeeds 335 + 336 + **Result:** Admin needs to seed forum metadata first 337 + 338 + ## Implementation Checklist 339 + 340 + From Linear issue ATB-15: 341 + 342 + - [ ] Create `apps/appview/src/lib/membership.ts` with `createMembershipForUser()` 343 + - [ ] Import and call in `apps/appview/src/routes/auth.ts` callback (after line 160) 344 + - [ ] Add comprehensive tests in `apps/appview/src/lib/__tests__/membership.test.ts` 345 + - [ ] Run full test suite: `pnpm test` 346 + - [ ] Manual test: OAuth login → verify membership on PDS → login again → no duplicate 347 + - [ ] Update Linear ATB-15: Backlog → In Progress (before starting) 348 + - [ ] Update Linear ATB-15: In Progress → Done (after tests pass) 349 + - [ ] Update `docs/atproto-forum-plan.md`: Mark Phase 2 item complete with note 350 + - [ ] Request code review (three-agent pattern: code-reviewer, silent-failure-hunter, pr-test-analyzer) 351 + 352 + ## Future Considerations (Out of Scope) 353 + 354 + - **Role assignment UI (ATB-17)** - Admin can update `role` field on existing memberships 355 + - **Membership revocation** - Admin writes modAction, indexer soft-deletes membership 356 + - **Invitation-only forums** - Require pre-existing membership before allowing login 357 + 358 + ## Success Criteria 359 + 360 + - ✅ User can complete OAuth login and automatically become a forum member 361 + - ✅ No duplicate memberships created on repeated logins 362 + - ✅ Login succeeds even if membership creation fails temporarily 363 + - ✅ Firehose eventually indexes membership record into database 364 + - ✅ All tests pass (unit, integration, error scenarios) 365 + - ✅ No regressions in existing OAuth flow