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 8 DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb 9 9 10 10 # Web UI configuration 11 - # PORT=3001 (set in web package, or override here) 11 + # WEB_PORT=3001 # set in web package, or override here 12 12 APPVIEW_URL=http://localhost:3000 13 13 14 14 # Forum Service Account credentials (for spike and AppView writes)
+2 -2
.env.production.example
··· 114 114 # AppView API server port (default: 3000) 115 115 # This is the internal port the appview service listens on. 116 116 # PORT=3000 117 + # WEB_PORT=3001 117 118 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. 119 + # Note: In the Docker container, nginx listens on port 80 and proxies to both services. 120 120 121 121 # ============================================================================ 122 122 # AT Protocol Features (Optional)
+1 -1
apps/appview/package.json
··· 29 29 }, 30 30 "devDependencies": { 31 31 "@types/node": "^22.0.0", 32 - "dotenv": "^17.2.4", 32 + "dotenv": "^17.3.1", 33 33 "tsx": "^4.0.0", 34 34 "typescript": "^5.7.0", 35 35 "vite": "^7.3.1",
+14 -3
apps/appview/src/lib/__tests__/membership.test.ts
··· 20 20 com: { 21 21 atproto: { 22 22 repo: { 23 - putRecord: vi.fn(), 23 + putRecord: vi.fn().mockResolvedValue({ 24 + data: { 25 + uri: "at://did:plc:test-user/space.atbb.membership/test", 26 + cid: "bafytest123", 27 + }, 28 + }), 24 29 }, 25 30 }, 26 31 }, ··· 58 63 it("throws when forum metadata not found", async () => { 59 64 const emptyCtx = await createTestContext({ emptyDb: true }); 60 65 61 - // Delete any existing forums from beforeEach hook 66 + // Delete memberships first (FK constraint), then forums 67 + await emptyCtx.db.delete(memberships); 62 68 await emptyCtx.db.delete(forums).where(eq(forums.rkey, "self")); 63 69 64 70 const mockAgent = { ··· 147 153 com: { 148 154 atproto: { 149 155 repo: { 150 - putRecord: vi.fn(), 156 + putRecord: vi.fn().mockResolvedValue({ 157 + data: { 158 + uri: "at://did:plc:duptest/space.atbb.membership/test", 159 + cid: "bafydup123", 160 + }, 161 + }), 151 162 }, 152 163 }, 153 164 },
+1 -1
apps/appview/src/lib/app-context.ts
··· 64 64 client_name: "atBB Forum", 65 65 client_uri: oauthUrl, 66 66 redirect_uris: [`${oauthUrl}/api/auth/callback`], 67 - scope: "atproto", 67 + scope: "atproto transition:generic", 68 68 grant_types: ["authorization_code", "refresh_token"], 69 69 response_types: ["code"], 70 70 application_type: "web",
+14 -3
apps/appview/vitest.config.ts
··· 1 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 }); 2 16 3 17 export default defineConfig({ 4 18 test: { 5 19 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 20 // Run test files sequentially to avoid database conflicts 10 21 // Tests share a single test database and use the same test DIDs 11 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 16 return mod.loadConfig(); 17 17 } 18 18 19 - it("returns default port 3001 when PORT is undefined", async () => { 20 - delete process.env.PORT; 19 + it("returns default port 3001 when WEB_PORT is undefined", async () => { 20 + delete process.env.WEB_PORT; 21 21 const config = await loadConfig(); 22 22 expect(config.port).toBe(3001); 23 23 }); 24 24 25 - it("parses PORT as an integer", async () => { 26 - process.env.PORT = "8080"; 25 + it("parses WEB_PORT as an integer", async () => { 26 + process.env.WEB_PORT = "8080"; 27 27 const config = await loadConfig(); 28 28 expect(config.port).toBe(8080); 29 29 expect(typeof config.port).toBe("number"); ··· 36 36 }); 37 37 38 38 it("uses provided environment variables", async () => { 39 - process.env.PORT = "9000"; 39 + process.env.WEB_PORT = "9000"; 40 40 process.env.APPVIEW_URL = "https://api.atbb.space"; 41 41 const config = await loadConfig(); 42 42 expect(config.port).toBe(9000); 43 43 expect(config.appviewUrl).toBe("https://api.atbb.space"); 44 44 }); 45 45 46 - it("returns NaN for port when PORT is empty string (?? does not catch empty strings)", async () => { 47 - process.env.PORT = ""; 46 + it("returns NaN for port when WEB_PORT is empty string (?? does not catch empty strings)", async () => { 47 + process.env.WEB_PORT = ""; 48 48 const config = await loadConfig(); 49 49 // Documents a gap: ?? only catches null/undefined, not "" 50 50 expect(config.port).toBeNaN();
+1 -1
apps/web/src/lib/config.ts
··· 5 5 6 6 export function loadConfig(): WebConfig { 7 7 return { 8 - port: parseInt(process.env.PORT ?? "3001", 10), 8 + port: parseInt(process.env.WEB_PORT ?? "3001", 10), 9 9 appviewUrl: process.env.APPVIEW_URL ?? "http://localhost:3000", 10 10 }; 11 11 }
+1
docs/atproto-forum-plan.md
··· 221 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 222 223 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. 224 225 - Nested/threaded replies 225 226 - Full-text search (maybe Meilisearch) 226 227 - User profiles & post history
+5 -5
pnpm-lock.yaml
··· 64 64 specifier: ^22.0.0 65 65 version: 22.19.9 66 66 dotenv: 67 - specifier: ^17.2.4 68 - version: 17.2.4 67 + specifier: ^17.3.1 68 + version: 17.3.1 69 69 tsx: 70 70 specifier: ^4.0.0 71 71 version: 4.21.0 ··· 1061 1061 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} 1062 1062 engines: {node: '>=6'} 1063 1063 1064 - dotenv@17.2.4: 1065 - resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} 1064 + dotenv@17.3.1: 1065 + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} 1066 1066 engines: {node: '>=12'} 1067 1067 1068 1068 drizzle-kit@0.31.8: ··· 2387 2387 2388 2388 deep-eql@5.0.2: {} 2389 2389 2390 - dotenv@17.2.4: {} 2390 + dotenv@17.3.1: {} 2391 2391 2392 2392 drizzle-kit@0.31.8: 2393 2393 dependencies:
+77 -33
scripts/test-membership-creation.sh
··· 1 1 #!/usr/bin/env bash 2 - set -euo pipefail 2 + # set -euo pipefail 3 3 4 4 # ATB-15 Manual Testing Helper Script 5 5 # Tests membership auto-creation during OAuth login ··· 48 48 set +a 49 49 50 50 # Verify required variables 51 - REQUIRED_VARS=("DATABASE_URL" "FORUM_DID" "PDS_URL") 51 + REQUIRED_VARS=("DATABASE_URL" "FORUM_DID" "PDS_URL" "OAUTH_PUBLIC_URL") 52 52 for var in "${REQUIRED_VARS[@]}"; do 53 53 if [ -z "${!var:-}" ]; then 54 54 print_error "Required environment variable $var is not set in .env" ··· 86 86 exit 1 87 87 fi 88 88 89 - # Check if membership already exists in database 90 - print_step "2" "Checking database for existing membership" 89 + # Ensure forum record exists in database 90 + print_step "2" "Ensuring forum record exists" 91 91 FORUM_URI="at://${FORUM_DID}/space.atbb.forum.forum/self" 92 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 + 93 111 MEMBERSHIP_COUNT=$(psql "$DATABASE_URL" -t -c \ 94 - "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" \ 112 + "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" \ 95 113 2>/dev/null | tr -d ' ') 96 114 97 115 if [ "$MEMBERSHIP_COUNT" -gt 0 ]; then ··· 100 118 echo 101 119 if [[ $REPLY =~ ^[Yy]$ ]]; then 102 120 psql "$DATABASE_URL" -c \ 103 - "DELETE FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" 121 + "DELETE FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" 104 122 print_success "Deleted existing membership from database" 105 123 fi 106 124 else ··· 108 126 fi 109 127 110 128 # Instructions for OAuth flow 111 - print_step "3" "OAuth Login 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" 112 145 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" 146 + print_info "Tip: Copy the URL above or use 'open \"$LOGIN_URL\"' on macOS" 119 147 echo "" 120 148 read -p "Press Enter after completing OAuth login..." 121 149 122 150 # 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..." 151 + print_step "5" "Checking server logs" 152 + print_info "Check your dev server console for these log events..." 125 153 126 - # Try to find logs (this assumes logs are in stdout/stderr of dev server) 127 154 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\"" 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)" 132 161 echo "" 133 - print_info "Check your dev server console for these log messages" 162 + print_info "The membership creation happens during the callback, so look for the membership event" 134 163 135 164 # Query database for indexed membership 136 - print_step "5" "Checking database for indexed membership" 165 + print_step "6" "Checking database for indexed membership" 137 166 sleep 2 # Give firehose a moment to index 138 167 139 168 MEMBERSHIP_DATA=$(psql "$DATABASE_URL" -c \ 140 - "SELECT did, rkey, \"forumUri\", \"joinedAt\", \"createdAt\", \"indexedAt\" 169 + "SELECT did, rkey, forum_uri, joined_at, created_at, indexed_at 141 170 FROM memberships 142 - WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" \ 171 + WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" \ 143 172 2>/dev/null) 144 173 145 174 if echo "$MEMBERSHIP_DATA" | grep -q "$TEST_DID"; then ··· 152 181 fi 153 182 154 183 # Test repeated login 155 - print_step "6" "Test repeated login (no duplicate)" 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" 156 195 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\"" 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" 162 206 echo "" 163 - read -p "Press Enter after completing repeated login test..." 207 + read -p "Press Enter after completing repeated login..." 164 208 165 209 # Verify no duplicate in database 166 210 FINAL_COUNT=$(psql "$DATABASE_URL" -t -c \ 167 - "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" \ 211 + "SELECT COUNT(*) FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" \ 168 212 2>/dev/null | tr -d ' ') 169 213 170 214 if [ "$FINAL_COUNT" -eq 1 ]; then ··· 172 216 elif [ "$FINAL_COUNT" -gt 1 ]; then 173 217 print_error "FAIL: Found $FINAL_COUNT memberships (duplicates created!)" 174 218 psql "$DATABASE_URL" -c \ 175 - "SELECT * FROM memberships WHERE did = '$TEST_DID' AND \"forumUri\" = '$FORUM_URI';" 219 + "SELECT * FROM memberships WHERE did = '$TEST_DID' AND forum_uri = '$FORUM_URI';" 176 220 exit 1 177 221 else 178 222 print_error "FAIL: No membership found in database"