diff --git a/app/api/galaxy/route.ts b/app/api/galaxy/route.ts index 0449fac..b3017b8 100644 --- a/app/api/galaxy/route.ts +++ b/app/api/galaxy/route.ts @@ -21,42 +21,71 @@ interface LinkData { * GET /api/galaxy * * Fetches nodes with 3D coordinates and their links for visualization. - * Automatically triggers graph calculation if needed. + * Supports public viewing via ?user={did} parameter. + * If no user parameter is provided and user is authenticated, shows their own galaxy. + * If no user parameter and not authenticated, returns empty state with guidance. */ export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const targetUserDid = searchParams.get('user'); + const cookieStore = await cookies(); const surrealJwt = cookieStore.get('ponderants-auth')?.value; - if (!surrealJwt) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } + // Determine which user's galaxy to show + let userDid: string; + let isOwnGalaxy = false; - // Verify JWT to get user's DID - const userSession = verifySurrealJwt(surrealJwt); - if (!userSession) { - return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 }); + if (targetUserDid) { + // Viewing someone else's public galaxy + userDid = targetUserDid; + console.log(`[Galaxy API] Fetching public galaxy for user: ${userDid}`); + } else if (surrealJwt) { + // Viewing own galaxy (authenticated) + const userSession = verifySurrealJwt(surrealJwt); + if (!userSession) { + return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 }); + } + userDid = userSession.did; + isOwnGalaxy = true; + console.log(`[Galaxy API] Fetching own galaxy for user: ${userDid}`); + } else { + // No target user and not authenticated - return empty galaxy with message + console.log('[Galaxy API] No user specified and not authenticated'); + return NextResponse.json({ + nodes: [], + links: [], + message: 'Log in to view your galaxy, or visit a public galaxy via ?user={did}', + }); } - const { did: userDid } = userSession; - try { const db = await connectToDB(); // Fetch nodes that have 3D coordinates + // When viewing own galaxy, show all nodes (including private) + // When viewing public galaxy, only show nodes where is_public = true const nodesQuery = ` SELECT id, title, body, user_did, atp_uri, coords_3d FROM node - WHERE user_did = $userDid AND coords_3d != NONE + WHERE user_did = $userDid + AND coords_3d != NONE + ${isOwnGalaxy ? '' : 'AND is_public = true'} `; const nodeResults = await db.query<[NodeData[]]>(nodesQuery, { userDid }); const nodes = nodeResults[0] || []; - // Fetch links between nodes + // Fetch links between visible nodes + // Extract node IDs for filtering links + const nodeIds = nodes.map((n) => n.id); + + // Only fetch links where both endpoints are in our visible nodes const linksQuery = ` SELECT in, out FROM links_to + WHERE in IN $nodeIds AND out IN $nodeIds `; - const linkResults = await db.query<[LinkData[]]>(linksQuery); + const linkResults = await db.query<[LinkData[]]>(linksQuery, { nodeIds }); const links = linkResults[0] || []; // Note: Coordinate calculation is now triggered automatically when nodes are created diff --git a/components/ThoughtGalaxy.tsx b/components/ThoughtGalaxy.tsx index 72f1c6e..ec2fe20 100644 --- a/components/ThoughtGalaxy.tsx +++ b/components/ThoughtGalaxy.tsx @@ -95,18 +95,25 @@ export function ThoughtGalaxy() { const [nodes, setNodes] = useState([]); const [links, setLinks] = useState([]); const [selectedNode, setSelectedNode] = useState(null); + const [emptyMessage, setEmptyMessage] = useState(null); const cameraControlsRef = useRef(null); const hasFitCamera = useRef(false); const hasFocusedNode = useRef(null); - // Get selectedNodeId from URL query params + // Get query params const selectedNodeId = searchParams.get('node'); + const targetUserDid = searchParams.get('user'); // For viewing someone else's galaxy // Fetch data from API on mount and poll for updates useEffect(() => { async function fetchData() { try { - const response = await fetch('/api/galaxy', { + // Build URL with optional user parameter + const url = targetUserDid + ? `/api/galaxy?user=${encodeURIComponent(targetUserDid)}` + : '/api/galaxy'; + + const response = await fetch(url, { credentials: 'include', // Include cookies for authentication }); @@ -119,13 +126,17 @@ export function ThoughtGalaxy() { if (data.message) { console.log('[ThoughtGalaxy]', data.message); + setEmptyMessage(data.message); // If calculating, poll again in 2 seconds - setTimeout(fetchData, 2000); + if (data.message.includes('calculating')) { + setTimeout(fetchData, 2000); + } return; } setNodes(data.nodes || []); setLinks(data.links || []); + setEmptyMessage(null); console.log(`[ThoughtGalaxy] Loaded ${data.nodes?.length || 0} nodes and ${data.links?.length || 0} links`); } catch (error) { @@ -134,7 +145,7 @@ export function ThoughtGalaxy() { } fetchData(); - }, []); + }, [targetUserDid]); // Function to fit camera to all nodes const fitCameraToNodes = () => { @@ -286,17 +297,49 @@ export function ThoughtGalaxy() { return ( - Create at least 3 nodes to visualize your thought galaxy - - - Nodes with content will automatically generate embeddings and 3D coordinates + {emptyMessage || 'Create at least 3 nodes to visualize your thought galaxy'} + {!emptyMessage && ( + + Nodes with content will automatically generate embeddings and 3D coordinates + + )} + {targetUserDid && ( + + Viewing galaxy for user: {targetUserDid} + + )} ); } return ( <> + {/* User info banner when viewing someone else's galaxy */} + {targetUserDid && ( + + + + Public Galaxy + + + Viewing {nodes.length} public {nodes.length === 1 ? 'node' : 'nodes'} + + + {targetUserDid} + + + + )} + {/* Floating content overlay for selected node */} {selectedNode && ( > -- Must be NONE or a 3-point array [x, y, z]. ASSERT $value = NONE OR array::len($value) = 3; +-- Privacy setting: Whether this node is publicly visible. +-- Defaults to true (public by default, aligns with ATproto philosophy). +-- When false, node is only visible to the owner. +DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true; + -- Define the vector search index. -- We use MTREE (or HNSW) for high-performance k-NN search. -- The dimension (3072) MUST match the output of the diff --git a/plans/10-public-galaxy-viewing.md b/plans/10-public-galaxy-viewing.md new file mode 100644 index 0000000..2010357 --- /dev/null +++ b/plans/10-public-galaxy-viewing.md @@ -0,0 +1,317 @@ +# Plan: Make Galaxy Viewable Without Login Requirement + +## Status +- **Priority**: HIGH (User-requested) +- **Status**: In Progress +- **Created**: 2025-01-10 + +## Problem Statement + +Currently, the galaxy visualization (`/galaxy`) requires user authentication via JWT cookie. This prevents: +1. Public sharing of thought galaxies +2. First-time visitors from seeing example galaxies +3. Social media link previews from working properly +4. Search engines from indexing public thought networks + +The galaxy should be publicly viewable while still respecting user privacy preferences. + +## Current Implementation Analysis + +### Authentication Check +`app/api/galaxy/route.ts` (lines 27-38): +```typescript +const surrealJwt = cookieStore.get('ponderants-auth')?.value; + +if (!surrealJwt) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); +} + +const userSession = verifySurrealJwt(surrealJwt); +if (!userSession) { + return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 }); +} +``` + +### Data Query +Currently queries `WHERE user_did = $userDid` (line 49), showing only the authenticated user's nodes. + +## Design Decisions + +### Option 1: Public by Default (RECOMMENDED) +**All galaxies are publicly viewable**, but users can mark individual nodes as private. + +**Pros:** +- Simple implementation +- Encourages public knowledge sharing +- Better for SEO and discovery +- Aligns with "decentralized social" vision + +**Cons:** +- Users might accidentally share private thoughts +- Requires clear UI indicators for node visibility + +**URL Structure:** +- `/galaxy` - current user's galaxy (if logged in) or landing page +- `/galaxy/{user_did}` or `/galaxy?user={user_did}` - specific user's public galaxy + +### Option 2: Opt-in Public Galleries +Galaxies are private by default, users must explicitly make them public. + +**Pros:** +- More privacy-conscious +- Users have full control + +**Cons:** +- Reduces discovery and sharing +- More complex implementation +- Goes against ATproto's "public by default" philosophy + +**Decision: We'll implement Option 1** - Public by default, with optional private nodes. + +## Implementation Plan + +### Phase 1: Update API to Support Public Access + +#### 1.1 Modify `/api/galaxy/route.ts` +```typescript +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const targetUserDid = searchParams.get('user'); + + const cookieStore = await cookies(); + const surrealJwt = cookieStore.get('ponderants-auth')?.value; + + // Determine which user's galaxy to show + let userDid: string; + let isOwnGalaxy = false; + + if (targetUserDid) { + // Viewing someone else's public galaxy + userDid = targetUserDid; + } else if (surrealJwt) { + // Viewing own galaxy (authenticated) + const userSession = verifySurrealJwt(surrealJwt); + if (!userSession) { + return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 }); + } + userDid = userSession.did; + isOwnGalaxy = true; + } else { + // No target user and not authenticated - return empty galaxy with message + return NextResponse.json({ + nodes: [], + links: [], + message: 'Log in to view your galaxy, or visit a public galaxy via ?user={did}' + }); + } + + // Query nodes + const nodesQuery = ` + SELECT id, title, body, user_did, atp_uri, coords_3d + FROM node + WHERE user_did = $userDid + AND coords_3d != NONE + ${isOwnGalaxy ? '' : 'AND is_public = true'} + `; + + // ... rest of implementation +} +``` + +#### 1.2 Add `is_public` Field to Node Schema +- Default: `true` (public by default) +- Users can mark individual nodes as private +- Private nodes are only visible to the owner + +### Phase 2: Update Database Schema + +#### 2.1 Add `is_public` Column to `node` Table +```sql +DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true; +``` + +#### 2.2 Create Migration Script +`scripts/add-is-public-field.ts`: +```typescript +import { connectToDB } from '@/lib/db'; + +async function migrate() { + const db = await connectToDB(); + + // Add field definition + await db.query(` + DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true; + `); + + // Set existing nodes to public + await db.query(` + UPDATE node SET is_public = true WHERE is_public = NONE; + `); + + console.log('Migration complete: Added is_public field'); +} + +migrate(); +``` + +### Phase 3: Update Frontend Components + +#### 3.1 Update `ThoughtGalaxy.tsx` +```typescript +// Support viewing other users' galaxies +const { searchParams } = useSearchParams(); +const targetUser = searchParams.get('user'); + +useEffect(() => { + async function fetchData() { + const url = targetUser + ? `/api/galaxy?user=${targetUser}` + : '/api/galaxy'; + + const response = await fetch(url, { + credentials: 'include', + }); + + // ... rest of implementation + } + + fetchData(); +}, [targetUser]); +``` + +#### 3.2 Add User Info Display +When viewing another user's galaxy, show: +- User's Bluesky handle +- Link to their profile +- Number of public nodes + +#### 3.3 Update `/galaxy` Page +Add support for URL parameter: `/galaxy?user=did:plc:xxxxx` + +### Phase 4: Navigation & User Experience + +#### 4.1 Landing Experience for Non-Authenticated Users +When visiting `/galaxy` without login: +- Show a sample/demo galaxy (could be a curated example) +- Display call-to-action: "Create your own thought galaxy" +- Provide login button + +#### 4.2 Add "Share Galaxy" Feature +Add button to copy shareable link: +```typescript +const shareUrl = `${window.location.origin}/galaxy?user=${userDid}`; +``` + +### Phase 5: Privacy Controls (Future Enhancement) + +#### 5.1 Node-Level Privacy Toggle +In node editor, add checkbox: +```typescript + setIsPublic(e.currentTarget.checked)} +/> +``` + +#### 5.2 Bulk Privacy Management +Settings page to: +- Make all nodes private/public +- Set default for new nodes +- Filter and update specific nodes + +## Security Considerations + +### 1. Data Exposure +- **Risk**: Users accidentally share sensitive information +- **Mitigation**: + - Clear visual indicators for public/private nodes + - Confirmation dialog when publishing nodes + - Easy way to make nodes private retroactively + +### 2. API Abuse +- **Risk**: Scraping or excessive requests to public galaxies +- **Mitigation**: + - Rate limiting on `/api/galaxy` + - Caching layer for public galaxies + - Consider CDN for popular galaxies + +### 3. Privacy Violations +- **Risk**: Viewing history tracking or surveillance +- **Mitigation**: + - No analytics on public galaxy views + - No "who viewed my galaxy" feature + - Respect DNT headers + +## Testing Plan + +### Magnitude Tests + +#### Test 1: Public Galaxy Viewing Without Auth +```typescript +test('Unauthenticated users can view public galaxies', async (agent) => { + await agent.act('Navigate to /galaxy?user=did:plc:example123'); + await agent.check('The galaxy visualization is displayed'); + await agent.check('Public nodes are visible'); + await agent.act('Click on a public node'); + await agent.check('Node details are displayed'); +}); +``` + +#### Test 2: Private Nodes Hidden from Public View +```typescript +test('Private nodes are not visible in public galaxy', async (agent) => { + // ... implementation +}); +``` + +#### Test 3: Own Galaxy Requires Auth +```typescript +test('Accessing own galaxy without target user requires authentication', async (agent) => { + await agent.act('Navigate to /galaxy'); + await agent.check('Login prompt or empty state is displayed'); +}); +``` + +### Manual Testing Checklist +- [ ] Visit `/galaxy` without login → see landing page +- [ ] Visit `/galaxy?user={valid_did}` → see public nodes +- [ ] Visit `/galaxy?user={invalid_did}` → see error message +- [ ] Log in and visit `/galaxy` → see own galaxy (including private nodes) +- [ ] Share galaxy link → recipient can view public nodes +- [ ] Mark node as private → confirm it disappears from public view + +## Implementation Steps + +1. **Create database migration** for `is_public` field +2. **Update API route** to support public access +3. **Update ThoughtGalaxy component** to handle URL parameters +4. **Add user info display** for public galaxies +5. **Test with manual checks** +6. **Write Magnitude tests** +7. **Update documentation** +8. **Create PR with changes** + +## Acceptance Criteria + +✅ Unauthenticated users can view public galaxies via `?user=` parameter +✅ Authenticated users see their own galaxy at `/galaxy` (no param) +✅ Private nodes are only visible to the owner +✅ Public nodes are visible to everyone +✅ Clear error messages for invalid user DIDs +✅ Shareable URLs work correctly +✅ All tests pass + +## Notes + +- This aligns with ATproto's philosophy of public-by-default, user-controlled data +- Future enhancement: Node-level privacy controls in UI +- Consider adding Open Graph meta tags for social media previews +- May want to add a "featured galaxies" page for discovery + +## Related Files + +- `app/api/galaxy/route.ts` - Galaxy API endpoint +- `components/ThoughtGalaxy.tsx` - 3D visualization component +- `app/galaxy/page.tsx` - Galaxy page component +- `lib/db/schema.surql` - Database schema diff --git a/scripts/add-is-public-field.ts b/scripts/add-is-public-field.ts new file mode 100644 index 0000000..07e7415 --- /dev/null +++ b/scripts/add-is-public-field.ts @@ -0,0 +1,44 @@ +import Surreal from 'surrealdb'; + +/** + * Migration: Add is_public field to node table + * + * This script adds the is_public field to all existing nodes, + * defaulting them to public (true) to align with the public-by-default + * philosophy of ATproto. + */ +async function migrate() { + const db = new Surreal(); + + try { + await db.connect('ws://localhost:8000/rpc'); + await db.signin({ username: 'root', password: 'root' }); + await db.use({ namespace: 'ponderants', database: 'main' }); + + console.log('Adding is_public field definition to node table...'); + + // Define the field (should already be in schema.surql, but ensuring it's applied) + await db.query(` + DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true; + `); + + console.log('✓ Field definition added'); + + console.log('Setting existing nodes to public...'); + + // Set all existing nodes where is_public is NONE to true (public by default) + const result = await db.query(` + UPDATE node SET is_public = true WHERE is_public = NONE; + `); + + console.log('Result:', result); + console.log('✓ Migration complete: All existing nodes are now public by default'); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } finally { + await db.close(); + } +} + +migrate(); diff --git a/tests/magnitude/03-public-galaxy.mag.ts b/tests/magnitude/03-public-galaxy.mag.ts new file mode 100644 index 0000000..5be2b18 --- /dev/null +++ b/tests/magnitude/03-public-galaxy.mag.ts @@ -0,0 +1,133 @@ +import { test } from 'magnitude-test'; + +/** + * Public Galaxy Viewing Tests + * + * These tests verify that: + * 1. Galaxies can be viewed publicly via ?user={did} parameter + * 2. Private nodes are hidden from public view + * 3. Own galaxy requires authentication + * 4. Invalid user DIDs are handled gracefully + */ + +test('Unauthenticated users can view public galaxies via user parameter', async (agent) => { + // Navigate to a public galaxy using the user parameter + // Note: This test assumes there's at least one user with public nodes + await agent.act('Navigate to /galaxy?user=did:plc:example123'); + + // Check: The galaxy visualization should be displayed + await agent.check('The 3D galaxy visualization is visible'); + + // Check: Public nodes should be visible + await agent.check('At least one node sphere is visible in the 3D space'); + + // Check: A "Public Galaxy" banner should be shown + await agent.check('A banner or indicator shows this is a public galaxy'); + + // Check: Can interact with nodes + await agent.act('Click on a visible node sphere'); + await agent.check('A modal or panel displays the node title and content'); +}); + +test('Private nodes are not visible in public galaxy view', async (agent) => { + // This test requires a user with both public and private nodes + // Navigate to their public galaxy + await agent.act('Navigate to /galaxy?user=did:plc:user-with-private-nodes'); + + // Check: Only public nodes are visible + await agent.check('The galaxy shows only public nodes'); + + // Check: Private nodes are not visible in the visualization + await agent.check('Private nodes are not rendered in the 3D space'); +}); + +test('Accessing own galaxy without user parameter shows authenticated galaxy', async (agent) => { + // First, log in + await agent.act('Navigate to /'); + await agent.act('Click the login button'); + await agent.act('Complete the Bluesky OAuth flow'); + + // Navigate to /galaxy without user parameter + await agent.act('Navigate to /galaxy'); + + // Check: Should see own galaxy (including private nodes) + await agent.check('The galaxy visualization shows all nodes including private ones'); + + // Check: No "Public Galaxy" banner should be shown (it's your own galaxy) + await agent.check('There is no "Public Galaxy" banner visible'); +}); + +test('Accessing own galaxy without authentication shows empty state', async (agent) => { + // Ensure user is logged out + await agent.act('Navigate to /'); + await agent.act('If logged in, click logout'); + + // Navigate to /galaxy without user parameter + await agent.act('Navigate to /galaxy'); + + // Check: Should see a message about logging in or viewing a public galaxy + await agent.check('A message is displayed about logging in or visiting a public galaxy'); + + // Check: No nodes are visible + await agent.check('No node spheres are visible in the visualization'); +}); + +test('Invalid user DID shows appropriate error or empty state', async (agent) => { + // Navigate to galaxy with an invalid/non-existent user DID + await agent.act('Navigate to /galaxy?user=did:plc:invalid-nonexistent-user'); + + // Check: Should handle gracefully (empty state or error message) + await agent.check('An appropriate message is shown for invalid or empty galaxy'); + + // Check: No crash or loading spinner stuck + await agent.check('The page is in a stable state (not stuck loading)'); +}); + +test('Public galaxy displays user information', async (agent) => { + // Navigate to a public galaxy + await agent.act('Navigate to /galaxy?user=did:plc:example123'); + + // Check: User DID or handle is displayed + await agent.check('The user DID is visible in the UI'); + + // Check: Node count is displayed + await agent.check('The number of public nodes is shown'); +}); + +test('Public galaxy node details show Bluesky link', async (agent) => { + // Navigate to a public galaxy + await agent.act('Navigate to /galaxy?user=did:plc:example123'); + + // Act: Click on a node + await agent.act('Click on a visible node sphere'); + + // Check: Node details modal is shown + await agent.check('A modal displays the node title and body'); + + // Check: "View on Bluesky" link is present + await agent.check('A "View on Bluesky" link is visible'); + + // Check: Link points to Bluesky + await agent.check('The link URL includes "bsky.app"'); +}); + +test('Can switch between own galaxy and public galaxy', async (agent) => { + // Log in + await agent.act('Navigate to /'); + await agent.act('Click the login button'); + await agent.act('Complete the Bluesky OAuth flow'); + + // View own galaxy + await agent.act('Navigate to /galaxy'); + await agent.check('Own galaxy is displayed'); + + // Navigate to someone else's public galaxy + await agent.act('Navigate to /galaxy?user=did:plc:other-user'); + await agent.check('The "Public Galaxy" banner is shown'); + await agent.check('A different set of nodes is visible'); + + // Navigate back to own galaxy + await agent.act('Navigate to /galaxy'); + await agent.check('Own galaxy is displayed again'); + await agent.check('No "Public Galaxy" banner is shown'); +});