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

chore: get the membership test script working and address bugs found during testing

+124 -71
+1 -1
.env.example
··· 8 DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb 9 10 # Web UI configuration 11 - # PORT=3001 (set in web package, or override here) 12 APPVIEW_URL=http://localhost:3000 13 14 # Forum Service Account credentials (for spike and AppView writes)
··· 8 DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb 9 10 # Web UI configuration 11 + # WEB_PORT=3001 # set in web package, or override here 12 APPVIEW_URL=http://localhost:3000 13 14 # Forum Service Account credentials (for spike and AppView writes)
+2 -2
.env.production.example
··· 114 # AppView API server port (default: 3000) 115 # This is the internal port the appview service listens on. 116 # PORT=3000 117 118 - # Note: The web service also uses PORT (not WEB_PORT) and defaults to 3001. 119 - # In the Docker container, nginx listens on port 80 and proxies to both services. 120 121 # ============================================================================ 122 # AT Protocol Features (Optional)
··· 114 # AppView API server port (default: 3000) 115 # This is the internal port the appview service listens on. 116 # PORT=3000 117 + # WEB_PORT=3001 118 119 + # Note: In the Docker container, nginx listens on port 80 and proxies to both services. 120 121 # ============================================================================ 122 # AT Protocol Features (Optional)
+1 -1
apps/appview/package.json
··· 29 }, 30 "devDependencies": { 31 "@types/node": "^22.0.0", 32 - "dotenv": "^17.2.4", 33 "tsx": "^4.0.0", 34 "typescript": "^5.7.0", 35 "vite": "^7.3.1",
··· 29 }, 30 "devDependencies": { 31 "@types/node": "^22.0.0", 32 + "dotenv": "^17.3.1", 33 "tsx": "^4.0.0", 34 "typescript": "^5.7.0", 35 "vite": "^7.3.1",
+14 -3
apps/appview/src/lib/__tests__/membership.test.ts
··· 20 com: { 21 atproto: { 22 repo: { 23 - putRecord: vi.fn(), 24 }, 25 }, 26 }, ··· 58 it("throws when forum metadata not found", async () => { 59 const emptyCtx = await createTestContext({ emptyDb: true }); 60 61 - // Delete any existing forums from beforeEach hook 62 await emptyCtx.db.delete(forums).where(eq(forums.rkey, "self")); 63 64 const mockAgent = { ··· 147 com: { 148 atproto: { 149 repo: { 150 - putRecord: vi.fn(), 151 }, 152 }, 153 },
··· 20 com: { 21 atproto: { 22 repo: { 23 + putRecord: vi.fn().mockResolvedValue({ 24 + data: { 25 + uri: "at://did:plc:test-user/space.atbb.membership/test", 26 + cid: "bafytest123", 27 + }, 28 + }), 29 }, 30 }, 31 }, ··· 63 it("throws when forum metadata not found", async () => { 64 const emptyCtx = await createTestContext({ emptyDb: true }); 65 66 + // Delete memberships first (FK constraint), then forums 67 + await emptyCtx.db.delete(memberships); 68 await emptyCtx.db.delete(forums).where(eq(forums.rkey, "self")); 69 70 const mockAgent = { ··· 153 com: { 154 atproto: { 155 repo: { 156 + putRecord: vi.fn().mockResolvedValue({ 157 + data: { 158 + uri: "at://did:plc:duptest/space.atbb.membership/test", 159 + cid: "bafydup123", 160 + }, 161 + }), 162 }, 163 }, 164 },
+1 -1
apps/appview/src/lib/app-context.ts
··· 64 client_name: "atBB Forum", 65 client_uri: oauthUrl, 66 redirect_uris: [`${oauthUrl}/api/auth/callback`], 67 - scope: "atproto", 68 grant_types: ["authorization_code", "refresh_token"], 69 response_types: ["code"], 70 application_type: "web",
··· 64 client_name: "atBB Forum", 65 client_uri: oauthUrl, 66 redirect_uris: [`${oauthUrl}/api/auth/callback`], 67 + scope: "atproto transition:generic", 68 grant_types: ["authorization_code", "refresh_token"], 69 response_types: ["code"], 70 application_type: "web",
+14 -3
apps/appview/vitest.config.ts
··· 1 import { defineConfig } from "vitest/config"; 2 3 export default defineConfig({ 4 test: { 5 environment: "node", 6 - // Load .env file before tests via setup file 7 - // This allows process.env to work naturally (GitHub Actions vars pass through) 8 - setupFiles: ["./vitest.setup.ts"], 9 // Run test files sequentially to avoid database conflicts 10 // Tests share a single test database and use the same test DIDs 11 fileParallelism: false,
··· 1 import { defineConfig } from "vitest/config"; 2 + import { config as loadDotenv } from "dotenv"; 3 + import { resolve } from "node:path"; 4 + import { existsSync } from "node:fs"; 5 + 6 + // Load .env file from monorepo root (for local dev) 7 + // Try main repo path first (../../.env from apps/appview) 8 + // If not found, try worktree path (../../../../.env from .worktrees/branch/apps/appview) 9 + const mainRepoPath = resolve(__dirname, "../../.env"); 10 + const worktreePath = resolve(__dirname, "../../../../.env"); 11 + const envPath = existsSync(mainRepoPath) ? mainRepoPath : worktreePath; 12 + 13 + // Load .env at config time so variables are available when tests run 14 + // dotenv won't override existing environment variables (e.g., from CI) 15 + loadDotenv({ path: envPath }); 16 17 export default defineConfig({ 18 test: { 19 environment: "node", 20 // Run test files sequentially to avoid database conflicts 21 // Tests share a single test database and use the same test DIDs 22 fileParallelism: false,
-14
apps/appview/vitest.setup.ts
··· 1 - import { config } from "dotenv"; 2 - import { resolve } from "node:path"; 3 - import { existsSync } from "node:fs"; 4 - 5 - // Load .env file from monorepo root (for local dev) 6 - // In CI, DATABASE_URL is already set by GitHub Actions workflow 7 - // dotenv() won't override existing environment variables 8 - 9 - // Try main repo path first (../../.env from apps/appview) 10 - // If not found, try worktree path (../../../../.env from .worktrees/branch/apps/appview) 11 - const mainRepoPath = resolve(__dirname, "../../.env"); 12 - const worktreePath = resolve(__dirname, "../../../../.env"); 13 - const envPath = existsSync(mainRepoPath) ? mainRepoPath : worktreePath; 14 - config({ path: envPath });
···
+7 -7
apps/web/src/lib/__tests__/config.test.ts
··· 16 return mod.loadConfig(); 17 } 18 19 - it("returns default port 3001 when PORT is undefined", async () => { 20 - delete process.env.PORT; 21 const config = await loadConfig(); 22 expect(config.port).toBe(3001); 23 }); 24 25 - it("parses PORT as an integer", async () => { 26 - process.env.PORT = "8080"; 27 const config = await loadConfig(); 28 expect(config.port).toBe(8080); 29 expect(typeof config.port).toBe("number"); ··· 36 }); 37 38 it("uses provided environment variables", async () => { 39 - process.env.PORT = "9000"; 40 process.env.APPVIEW_URL = "https://api.atbb.space"; 41 const config = await loadConfig(); 42 expect(config.port).toBe(9000); 43 expect(config.appviewUrl).toBe("https://api.atbb.space"); 44 }); 45 46 - it("returns NaN for port when PORT is empty string (?? does not catch empty strings)", async () => { 47 - process.env.PORT = ""; 48 const config = await loadConfig(); 49 // Documents a gap: ?? only catches null/undefined, not "" 50 expect(config.port).toBeNaN();
··· 16 return mod.loadConfig(); 17 } 18 19 + it("returns default port 3001 when WEB_PORT is undefined", async () => { 20 + delete process.env.WEB_PORT; 21 const config = await loadConfig(); 22 expect(config.port).toBe(3001); 23 }); 24 25 + it("parses WEB_PORT as an integer", async () => { 26 + process.env.WEB_PORT = "8080"; 27 const config = await loadConfig(); 28 expect(config.port).toBe(8080); 29 expect(typeof config.port).toBe("number"); ··· 36 }); 37 38 it("uses provided environment variables", async () => { 39 + process.env.WEB_PORT = "9000"; 40 process.env.APPVIEW_URL = "https://api.atbb.space"; 41 const config = await loadConfig(); 42 expect(config.port).toBe(9000); 43 expect(config.appviewUrl).toBe("https://api.atbb.space"); 44 }); 45 46 + it("returns NaN for port when WEB_PORT is empty string (?? does not catch empty strings)", async () => { 47 + process.env.WEB_PORT = ""; 48 const config = await loadConfig(); 49 // Documents a gap: ?? only catches null/undefined, not "" 50 expect(config.port).toBeNaN();
+1 -1
apps/web/src/lib/config.ts
··· 5 6 export function loadConfig(): WebConfig { 7 return { 8 - port: parseInt(process.env.PORT ?? "3001", 10), 9 appviewUrl: process.env.APPVIEW_URL ?? "http://localhost:3000", 10 }; 11 }
··· 5 6 export function loadConfig(): WebConfig { 7 return { 8 + port: parseInt(process.env.WEB_PORT ?? "3001", 10), 9 appviewUrl: process.env.APPVIEW_URL ?? "http://localhost:3000", 10 }; 11 }
+1
docs/atproto-forum-plan.md
··· 221 React Native + Expo cross-platform apps consuming the same `/api/*` endpoints as the web UI. Phased rollout: read-only browse → write/interact → push notifications → offline support & app store release. Full plan in [`docs/mobile-apps-plan.md`](mobile-apps-plan.md). 222 223 ### Other Future Work 224 - Nested/threaded replies 225 - Full-text search (maybe Meilisearch) 226 - User profiles & post history
··· 221 React Native + Expo cross-platform apps consuming the same `/api/*` endpoints as the web UI. Phased rollout: read-only browse → write/interact → push notifications → offline support & app store release. Full plan in [`docs/mobile-apps-plan.md`](mobile-apps-plan.md). 222 223 ### Other Future Work 224 + - **Setup wizard for first-time initialization** — Interactive web-based wizard for administrators to initialize a new forum instance (create forum record on PDS, configure categories, set admin roles). Currently requires manual spike script execution or direct PDS API calls. 225 - Nested/threaded replies 226 - Full-text search (maybe Meilisearch) 227 - User profiles & post history
+5 -5
pnpm-lock.yaml
··· 64 specifier: ^22.0.0 65 version: 22.19.9 66 dotenv: 67 - specifier: ^17.2.4 68 - version: 17.2.4 69 tsx: 70 specifier: ^4.0.0 71 version: 4.21.0 ··· 1061 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} 1062 engines: {node: '>=6'} 1063 1064 - dotenv@17.2.4: 1065 - resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} 1066 engines: {node: '>=12'} 1067 1068 drizzle-kit@0.31.8: ··· 2387 2388 deep-eql@5.0.2: {} 2389 2390 - dotenv@17.2.4: {} 2391 2392 drizzle-kit@0.31.8: 2393 dependencies:
··· 64 specifier: ^22.0.0 65 version: 22.19.9 66 dotenv: 67 + specifier: ^17.3.1 68 + version: 17.3.1 69 tsx: 70 specifier: ^4.0.0 71 version: 4.21.0 ··· 1061 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} 1062 engines: {node: '>=6'} 1063 1064 + dotenv@17.3.1: 1065 + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} 1066 engines: {node: '>=12'} 1067 1068 drizzle-kit@0.31.8: ··· 2387 2388 deep-eql@5.0.2: {} 2389 2390 + dotenv@17.3.1: {} 2391 2392 drizzle-kit@0.31.8: 2393 dependencies:
+77 -33
scripts/test-membership-creation.sh
··· 1 #!/usr/bin/env bash 2 - set -euo pipefail 3 4 # ATB-15 Manual Testing Helper Script 5 # Tests membership auto-creation during OAuth login ··· 48 set +a 49 50 # Verify required variables 51 - REQUIRED_VARS=("DATABASE_URL" "FORUM_DID" "PDS_URL") 52 for var in "${REQUIRED_VARS[@]}"; do 53 if [ -z "${!var:-}" ]; then 54 print_error "Required environment variable $var is not set in .env" ··· 86 exit 1 87 fi 88 89 - # Check if membership already exists in database 90 - print_step "2" "Checking database for existing membership" 91 FORUM_URI="at://${FORUM_DID}/space.atbb.forum.forum/self" 92 93 MEMBERSHIP_COUNT=$(psql "$DATABASE_URL" -t -c \ 94 - "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" \ 95 2>/dev/null | tr -d ' ') 96 97 if [ "$MEMBERSHIP_COUNT" -gt 0 ]; then ··· 100 echo 101 if [[ $REPLY =~ ^[Yy]$ ]]; then 102 psql "$DATABASE_URL" -c \ 103 - "DELETE FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" 104 print_success "Deleted existing membership from database" 105 fi 106 else ··· 108 fi 109 110 # Instructions for OAuth flow 111 - print_step "3" "OAuth Login Flow" 112 echo "" 113 - echo "Manual steps:" 114 - echo " 1. Open browser to http://localhost:3001" 115 - echo " 2. Click 'Login' button" 116 - echo " 3. Enter handle: $TEST_HANDLE" 117 - echo " 4. Complete OAuth flow at PDS" 118 - echo " 5. Verify redirect to homepage" 119 echo "" 120 read -p "Press Enter after completing OAuth login..." 121 122 # Check server logs for membership creation 123 - print_step "4" "Checking server logs" 124 - print_info "Looking for membership creation logs in the last 60 seconds..." 125 126 - # Try to find logs (this assumes logs are in stdout/stderr of dev server) 127 echo "" 128 - echo "Expected log patterns:" 129 - echo " • First login: \"Membership record created\"" 130 - echo " • Repeated login: \"Membership already exists\"" 131 - echo " • Error case: \"Failed to create membership record - login will proceed\"" 132 echo "" 133 - print_info "Check your dev server console for these log messages" 134 135 # Query database for indexed membership 136 - print_step "5" "Checking database for indexed membership" 137 sleep 2 # Give firehose a moment to index 138 139 MEMBERSHIP_DATA=$(psql "$DATABASE_URL" -c \ 140 - "SELECT did, rkey, \"forumUri\", \"joinedAt\", \"createdAt\", \"indexedAt\" 141 FROM memberships 142 - WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" \ 143 2>/dev/null) 144 145 if echo "$MEMBERSHIP_DATA" | grep -q "$TEST_DID"; then ··· 152 fi 153 154 # Test repeated login 155 - print_step "6" "Test repeated login (no duplicate)" 156 echo "" 157 - echo "Manual steps:" 158 - echo " 1. Logout from forum (http://localhost:3001/api/auth/logout)" 159 - echo " 2. Login again with same account" 160 - echo " 3. Complete OAuth flow" 161 - echo " 4. Check logs for \"Membership already exists\"" 162 echo "" 163 - read -p "Press Enter after completing repeated login test..." 164 165 # Verify no duplicate in database 166 FINAL_COUNT=$(psql "$DATABASE_URL" -t -c \ 167 - "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" \ 168 2>/dev/null | tr -d ' ') 169 170 if [ "$FINAL_COUNT" -eq 1 ]; then ··· 172 elif [ "$FINAL_COUNT" -gt 1 ]; then 173 print_error "FAIL: Found $FINAL_COUNT memberships (duplicates created!)" 174 psql "$DATABASE_URL" -c \ 175 - "SELECT * FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" 176 exit 1 177 else 178 print_error "FAIL: No membership found in database"
··· 1 #!/usr/bin/env bash 2 + # set -euo pipefail 3 4 # ATB-15 Manual Testing Helper Script 5 # Tests membership auto-creation during OAuth login ··· 48 set +a 49 50 # Verify required variables 51 + REQUIRED_VARS=("DATABASE_URL" "FORUM_DID" "PDS_URL" "OAUTH_PUBLIC_URL") 52 for var in "${REQUIRED_VARS[@]}"; do 53 if [ -z "${!var:-}" ]; then 54 print_error "Required environment variable $var is not set in .env" ··· 86 exit 1 87 fi 88 89 + # Ensure forum record exists in database 90 + print_step "2" "Ensuring forum record exists" 91 FORUM_URI="at://${FORUM_DID}/space.atbb.forum.forum/self" 92 93 + FORUM_COUNT=$(psql "$DATABASE_URL" -t -c \ 94 + "SELECT COUNT(*) FROM forums WHERE did = '$FORUM_DID' AND rkey = 'self';" \ 95 + 2>/dev/null | tr -d ' ') 96 + 97 + if [ "$FORUM_COUNT" -eq 0 ]; then 98 + print_info "No forum record found. Creating one for testing..." 99 + psql "$DATABASE_URL" -c \ 100 + "INSERT INTO forums (did, rkey, cid, name, description, indexed_at) 101 + VALUES ('$FORUM_DID', 'self', 'bafytest123', 'Test Forum', 'Test forum for membership creation', NOW());" \ 102 + 2>/dev/null 103 + print_success "Created forum record in database" 104 + else 105 + print_success "Forum record already exists" 106 + fi 107 + 108 + # Check if membership already exists in database 109 + print_step "3" "Checking database for existing membership" 110 + 111 MEMBERSHIP_COUNT=$(psql "$DATABASE_URL" -t -c \ 112 + "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" \ 113 2>/dev/null | tr -d ' ') 114 115 if [ "$MEMBERSHIP_COUNT" -gt 0 ]; then ··· 118 echo 119 if [[ $REPLY =~ ^[Yy]$ ]]; then 120 psql "$DATABASE_URL" -c \ 121 + "DELETE FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" 122 print_success "Deleted existing membership from database" 123 fi 124 else ··· 126 fi 127 128 # Instructions for OAuth flow 129 + print_step "4" "OAuth Login Flow" 130 + echo "" 131 + 132 + # Construct OAuth login URL 133 + LOGIN_URL="${OAUTH_PUBLIC_URL}/api/auth/login?handle=${TEST_HANDLE}" 134 + 135 + echo "Open this URL in your browser to start OAuth login:" 136 + echo "" 137 + echo -e "${GREEN}${LOGIN_URL}${NC}" 138 + echo "" 139 + echo "What will happen:" 140 + echo " 1. Browser redirects to your PDS (${PDS_URL})" 141 + echo " 2. You approve the forum's access request" 142 + echo " 3. PDS redirects back to ${OAUTH_PUBLIC_URL}/api/auth/callback" 143 + echo " 4. Forum creates session and membership record" 144 + echo " 5. Browser redirects to homepage" 145 echo "" 146 + print_info "Tip: Copy the URL above or use 'open \"$LOGIN_URL\"' on macOS" 147 echo "" 148 read -p "Press Enter after completing OAuth login..." 149 150 # Check server logs for membership creation 151 + print_step "5" "Checking server logs" 152 + print_info "Check your dev server console for these log events..." 153 154 echo "" 155 + echo "Expected log events (JSON formatted):" 156 + echo " • oauth.login.initiated - OAuth flow started" 157 + echo " • oauth.callback.success - User authenticated successfully" 158 + echo " • oauth.callback.membership.created - New membership record created (first login)" 159 + echo " • oauth.callback.membership.exists - Membership already exists (repeated login)" 160 + echo " • oauth.callback.membership.failed - Membership creation failed (error case)" 161 echo "" 162 + print_info "The membership creation happens during the callback, so look for the membership event" 163 164 # Query database for indexed membership 165 + print_step "6" "Checking database for indexed membership" 166 sleep 2 # Give firehose a moment to index 167 168 MEMBERSHIP_DATA=$(psql "$DATABASE_URL" -c \ 169 + "SELECT did, rkey, forum_uri, joined_at, created_at, indexed_at 170 FROM memberships 171 + WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" \ 172 2>/dev/null) 173 174 if echo "$MEMBERSHIP_DATA" | grep -q "$TEST_DID"; then ··· 181 fi 182 183 # Test repeated login 184 + print_step "7" "Test repeated login (no duplicate)" 185 + echo "" 186 + 187 + # Construct logout and login URLs 188 + LOGOUT_URL="${OAUTH_PUBLIC_URL}/api/auth/logout" 189 + 190 + echo "First, logout to clear the current session:" 191 + echo "" 192 + echo -e "${GREEN}${LOGOUT_URL}${NC}" 193 + echo "" 194 + print_info "Tip: Open the URL or use 'open \"$LOGOUT_URL\"' on macOS" 195 echo "" 196 + read -p "Press Enter after logging out..." 197 + echo "" 198 + echo "Now login again with the same account:" 199 + echo "" 200 + echo -e "${GREEN}${LOGIN_URL}${NC}" 201 + echo "" 202 + echo "Expected behavior:" 203 + echo " • Login should succeed normally" 204 + echo " • Server logs should show \"Membership already exists\" (not \"created\")" 205 + echo " • No duplicate membership record in database" 206 echo "" 207 + read -p "Press Enter after completing repeated login..." 208 209 # Verify no duplicate in database 210 FINAL_COUNT=$(psql "$DATABASE_URL" -t -c \ 211 + "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" \ 212 2>/dev/null | tr -d ' ') 213 214 if [ "$FINAL_COUNT" -eq 1 ]; then ··· 216 elif [ "$FINAL_COUNT" -gt 1 ]; then 217 print_error "FAIL: Found $FINAL_COUNT memberships (duplicates created!)" 218 psql "$DATABASE_URL" -c \ 219 + "SELECT * FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" 220 exit 1 221 else 222 print_error "FAIL: No membership found in database"