feat: Make galaxy viewable without login requirement

Implemented public galaxy viewing feature that allows unauthenticated
users to view public thought galaxies via the ?user={did} parameter,
while maintaining privacy controls for node-level visibility.

Changes:
- Updated /api/galaxy/route.ts to support public access:
  * Accept ?user={did} query parameter for viewing specific user's galaxy
  * Show all nodes (including private) for authenticated user viewing own galaxy
  * Filter to only public nodes when viewing someone else's galaxy
  * Return empty state with helpful message when not authenticated
  * Filter links to only show connections between visible nodes

- Added is_public field to database schema:
  * Updated db/schema.surql with DEFAULT true (public by default)
  * Created migration script scripts/add-is-public-field.ts
  * Aligns with ATproto's public-by-default philosophy

- Enhanced ThoughtGalaxy component:
  * Support viewing galaxies via ?user={did} parameter
  * Display user info banner when viewing public galaxy
  * Show appropriate empty state messages based on context
  * Refetch data when user parameter changes

- Created comprehensive Magnitude tests:
  * Test public galaxy viewing without authentication
  * Verify private nodes are hidden from public view
  * Test own galaxy access requires authentication
  * Validate invalid user DID handling
  * Test user info display and navigation between galaxies

- Documented implementation plan in plans/10-public-galaxy-viewing.md

This implements the "public by default" model while allowing future
node-level privacy controls. All canonical data remains on the user's
ATproto PDS, with SurrealDB serving as a high-performance cache.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-10 00:36:16 +00:00
parent aa60098690
commit d656b06113
6 changed files with 592 additions and 21 deletions

View File

@@ -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

View File

@@ -95,18 +95,25 @@ export function ThoughtGalaxy() {
const [nodes, setNodes] = useState<NodeData[]>([]);
const [links, setLinks] = useState<LinkData[]>([]);
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
const cameraControlsRef = useRef<CameraControls>(null);
const hasFitCamera = useRef(false);
const hasFocusedNode = useRef<string | null>(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 (
<Stack align="center" justify="center" style={{ height: '100vh', width: '100vw' }}>
<MantineText size="lg" c="dimmed">
Create at least 3 nodes to visualize your thought galaxy
</MantineText>
<MantineText size="sm" c="dimmed">
Nodes with content will automatically generate embeddings and 3D coordinates
{emptyMessage || 'Create at least 3 nodes to visualize your thought galaxy'}
</MantineText>
{!emptyMessage && (
<MantineText size="sm" c="dimmed">
Nodes with content will automatically generate embeddings and 3D coordinates
</MantineText>
)}
{targetUserDid && (
<MantineText size="sm" c="dimmed" mt="xs">
Viewing galaxy for user: {targetUserDid}
</MantineText>
)}
</Stack>
);
}
return (
<>
{/* User info banner when viewing someone else's galaxy */}
{targetUserDid && (
<Box
style={{
position: 'absolute',
top: '10px',
right: '10px',
zIndex: 999,
maxWidth: '300px',
}}
>
<Paper p="sm" radius="md" withBorder shadow="md">
<MantineText size="sm" fw={600}>
Public Galaxy
</MantineText>
<MantineText size="xs" c="dimmed">
Viewing {nodes.length} public {nodes.length === 1 ? 'node' : 'nodes'}
</MantineText>
<MantineText size="xs" c="dimmed" style={{ wordBreak: 'break-all' }}>
{targetUserDid}
</MantineText>
</Paper>
</Box>
)}
{/* Floating content overlay for selected node */}
{selectedNode && (
<Box

View File

@@ -67,6 +67,11 @@ DEFINE FIELD coords_3d ON TABLE node TYPE option<array<number>>
-- 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

View File

@@ -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
<Checkbox
label="Make this node public"
checked={isPublic}
onChange={(e) => 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

View File

@@ -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();

View File

@@ -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');
});