From e1ab1d24ba15ee7a518591afeab6b321e94fd674 Mon Sep 17 00:00:00 2001 From: sahilm2002 Date: Tue, 9 Dec 2025 19:17:31 -0800 Subject: [PATCH 1/4] feat: complete user auth & sessions, add UI improvements - Add users table migration for Bluesky-authenticated users - Implement user service with getOrCreateUser and getUserByHandle - Complete verify-challenge endpoint to create users and sessions - Add session management helpers (getSession, clearSession) - Add logout endpoint - Enhance accounts used display on match and compare pages - Improve methodology page with account selection transparency - Add health check endpoint (/api/health) - Improve error messages throughout with actionable guidance - Add loading states and spinners for async operations - Add better error styling and user feedback --- src/lib/auth/session.ts | 42 ++++++ src/lib/services/userService.ts | 140 ++++++++++++++++++ .../api/auth/bsky/create-challenge/+server.ts | 42 +++++- .../api/auth/bsky/verify-challenge/+server.ts | 102 ++++++++++--- src/routes/api/auth/logout/+server.ts | 34 +++++ src/routes/api/health/+server.ts | 91 ++++++++++++ src/routes/compare/[matchId]/+page.svelte | 8 +- src/routes/match/[id]/+page.svelte | 78 ++++++++-- src/routes/methodology/+page.svelte | 23 ++- supabase/migrations/005_create_users.sql | 55 +++++++ 10 files changed, 565 insertions(+), 50 deletions(-) create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/services/userService.ts create mode 100644 src/routes/api/auth/logout/+server.ts create mode 100644 src/routes/api/health/+server.ts create mode 100644 supabase/migrations/005_create_users.sql diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..4ee5373 --- /dev/null +++ b/src/lib/auth/session.ts @@ -0,0 +1,42 @@ +import type { Cookies } from '@sveltejs/kit'; +import { getUserByHandle } from '$lib/services/userService'; + +export type SessionData = { + userId: string; + handle: string; + verifiedAt: string; +}; + +/** + * Get the current session from cookies. + * Returns null if no valid session exists. + */ +export function getSession(cookies: Cookies): SessionData | null { + try { + const sessionCookie = cookies.get('session'); + if (!sessionCookie) return null; + const session = JSON.parse(sessionCookie) as SessionData; + // Basic validation + if (!session.userId || !session.handle || !session.verifiedAt) return null; + return session; + } catch { + return null; + } +} + +/** + * Clear the session cookie. + */ +export function clearSession(cookies: Cookies): void { + cookies.delete('session', { path: '/' }); +} + +/** + * Get the current authenticated user's handle. + * Returns null if not authenticated. + */ +export function getCurrentUserHandle(cookies: Cookies): string | null { + const session = getSession(cookies); + return session?.handle ?? null; +} + diff --git a/src/lib/services/userService.ts b/src/lib/services/userService.ts new file mode 100644 index 0000000..30248b5 --- /dev/null +++ b/src/lib/services/userService.ts @@ -0,0 +1,140 @@ +import { getSupabaseAdmin } from '$lib/supabaseAdmin'; +import { BskyAgent } from '@atproto/api'; +import { BSKY_APPVIEW_BASE } from '$lib/config/bsky'; + +export type User = { + id: string; + bskyHandle: string; + bskyDid?: string | null; + displayName?: string | null; + avatarUrl?: string | null; + verifiedAt: string; + lastSeenAt: string; + createdAt: string; + updatedAt: string; +}; + +let agentPromise: Promise | null = null; + +function getServiceBase(): string { + return BSKY_APPVIEW_BASE.endsWith('/xrpc') + ? BSKY_APPVIEW_BASE.slice(0, -('/xrpc'.length)) + : BSKY_APPVIEW_BASE; +} + +async function getAgent(): Promise { + if (!agentPromise) { + agentPromise = (async () => { + const agent = new BskyAgent({ service: getServiceBase() }); + return agent; + })(); + } + return agentPromise; +} + +/** + * Resolve a Bluesky handle to its DID (Decentralized Identifier). + */ +async function resolveDidFromHandle(handle: string): Promise { + try { + const agent = await getAgent(); + const res: any = await (agent as any).getProfile?.({ actor: handle }); + return res?.data?.did ?? null; + } catch { + return null; + } +} + +/** + * Get or create a user from a Bluesky handle. + * If the user exists, updates last_seen_at and optionally DID/displayName. + * Returns the user record or null if Supabase is not configured. + */ +export async function getOrCreateUser(handle: string): Promise { + const admin = getSupabaseAdmin(); + if (!admin) return null; + + const normalizedHandle = handle.trim().toLowerCase(); + + // Try to resolve DID and profile info + const did = await resolveDidFromHandle(normalizedHandle); + let displayName: string | null = null; + let avatarUrl: string | null = null; + + if (did) { + try { + const agent = await getAgent(); + const profile: any = await (agent as any).getProfile?.({ actor: did }); + displayName = profile?.data?.displayName ?? null; + avatarUrl = profile?.data?.avatar ?? null; + } catch { + // Ignore profile fetch errors + } + } + + // Upsert user + const { data, error } = await admin + .from('users') + .upsert( + { + bsky_handle: normalizedHandle, + bsky_did: did, + display_name: displayName, + avatar_url: avatarUrl, + last_seen_at: new Date().toISOString() + }, + { + onConflict: 'bsky_handle', + ignoreDuplicates: false + } + ) + .select() + .single(); + + if (error) { + console.error('getOrCreateUser: upsert failed', { error: String(error) }); + return null; + } + + return { + id: data.id, + bskyHandle: data.bsky_handle, + bskyDid: data.bsky_did, + displayName: data.display_name, + avatarUrl: data.avatar_url, + verifiedAt: data.verified_at, + lastSeenAt: data.last_seen_at, + createdAt: data.created_at, + updatedAt: data.updated_at + }; +} + +/** + * Get a user by Bluesky handle. + */ +export async function getUserByHandle(handle: string): Promise { + const admin = getSupabaseAdmin(); + if (!admin) return null; + + const normalizedHandle = handle.trim().toLowerCase(); + const { data, error } = await admin + .from('users') + .select('*') + .eq('bsky_handle', normalizedHandle) + .single(); + + if (error || !data) return null; + + return { + id: data.id, + bskyHandle: data.bsky_handle, + bskyDid: data.bsky_did, + displayName: data.display_name, + avatarUrl: data.avatar_url, + verifiedAt: data.verified_at, + lastSeenAt: data.last_seen_at, + createdAt: data.created_at, + updatedAt: data.updated_at + }; +} + diff --git a/src/routes/api/auth/bsky/create-challenge/+server.ts b/src/routes/api/auth/bsky/create-challenge/+server.ts index ea47425..474483a 100644 --- a/src/routes/api/auth/bsky/create-challenge/+server.ts +++ b/src/routes/api/auth/bsky/create-challenge/+server.ts @@ -7,10 +7,30 @@ export const POST: RequestHandler = async ({ request }) => { const handle = (body?.handle ?? '').toString().trim(); if (!handle) { - return new Response(JSON.stringify({ error: 'missing_handle' }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + error: 'missing_handle', + message: 'Bluesky handle is required. Please provide your handle (e.g., "example.bsky.social").' + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Basic handle validation + if (!handle.includes('.') && !handle.includes('@')) { + return new Response( + JSON.stringify({ + error: 'invalid_handle', + message: 'Invalid Bluesky handle format. Handles should be in the format "example.bsky.social" or "@example.bsky.social".' + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); } // If an existing challenge exists and hasn't expired, reuse it (avoid generating multiple codes quickly) @@ -29,9 +49,15 @@ export const POST: RequestHandler = async ({ request }) => { { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } } ); } catch (e: any) { - return new Response(JSON.stringify({ error: 'create_challenge_failed', message: e?.message ?? 'Unknown error' }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + error: 'create_challenge_failed', + message: e?.message ?? 'An unexpected error occurred while creating the verification challenge. Please try again.' + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); } }; diff --git a/src/routes/api/auth/bsky/verify-challenge/+server.ts b/src/routes/api/auth/bsky/verify-challenge/+server.ts index 13b235b..cb7f634 100644 --- a/src/routes/api/auth/bsky/verify-challenge/+server.ts +++ b/src/routes/api/auth/bsky/verify-challenge/+server.ts @@ -1,21 +1,20 @@ import type { RequestHandler } from '@sveltejs/kit'; import { getChallenge, clearChallenge, verifyPostContainsCode } from '$lib/auth/bskyVerifyStore'; -import { getSupabaseClient } from '$lib/supabaseClient'; +import { getOrCreateUser } from '$lib/services/userService'; /** * POST /api/auth/bsky/verify-challenge * Body: { handle: string } * Verifies that the user posted the issued challenge code on Bluesky recently. - * If successful, clears the challenge and returns { ok: true }. - * (Skeleton for future: link/create user in Supabase or issue a session) + * If successful, creates/updates user in Supabase, issues a session cookie, and returns user info. */ -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json().catch(() => ({} as any)); const handle = (body?.handle ?? '').toString().trim(); if (!handle) { - return new Response(JSON.stringify({ error: 'missing_handle' }), { + return new Response(JSON.stringify({ error: 'missing_handle', message: 'Bluesky handle is required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); @@ -23,36 +22,93 @@ export const POST: RequestHandler = async ({ request }) => { const ch = getChallenge(handle); if (!ch) { - return new Response(JSON.stringify({ error: 'no_active_challenge' }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + error: 'no_active_challenge', + message: 'No active verification challenge found. Please create a new challenge first.' + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); } // Verify that the posted content contains the code const verify = await verifyPostContainsCode(handle, ch.code, 15); if (!verify.ok) { - return new Response(JSON.stringify({ error: 'verification_failed', reason: verify.reason }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + error: 'verification_failed', + reason: verify.reason, + message: `Verification failed: ${verify.reason === 'code_not_found_recent_posts' ? 'Code not found in recent posts. Please post the code and try again.' : 'Unable to verify post.'}` + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); } // Clear the challenge since it's been satisfied clearChallenge(handle); - // TODO: Link/create user in Supabase; issue session or magic link. - // const supabase = getSupabaseClient(); - // if (supabase) { ... } + // Create or update user in Supabase + const user = await getOrCreateUser(handle); + if (!user) { + return new Response( + JSON.stringify({ + error: 'user_creation_failed', + message: 'Failed to create user account. Please try again later.' + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); + } - return new Response(JSON.stringify({ ok: true, handle }), { - status: 200, - headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } + // Set session cookie (simple approach; in production, consider JWT or Supabase Auth) + // Cookie contains user ID and handle for session validation + const sessionData = { + userId: user.id, + handle: user.bskyHandle, + verifiedAt: user.verifiedAt + }; + cookies.set('session', JSON.stringify(sessionData), { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 // 30 days }); + + return new Response( + JSON.stringify({ + ok: true, + user: { + id: user.id, + handle: user.bskyHandle, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + verifiedAt: user.verifiedAt + } + }), + { + status: 200, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } + } + ); } catch (e: any) { - return new Response(JSON.stringify({ error: 'verify_challenge_failed', message: e?.message ?? 'Unknown error' }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + error: 'verify_challenge_failed', + message: e?.message ?? 'An unexpected error occurred during verification' + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); } }; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..14464e5 --- /dev/null +++ b/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,34 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { clearSession } from '$lib/auth/session'; + +/** + * POST /api/auth/logout + * Clears the user session cookie. + */ +export const POST: RequestHandler = async ({ cookies }) => { + try { + clearSession(cookies); + return new Response( + JSON.stringify({ + ok: true, + message: 'Successfully logged out' + }), + { + status: 200, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } + } + ); + } catch (e: any) { + return new Response( + JSON.stringify({ + error: 'logout_failed', + message: e?.message ?? 'An error occurred while logging out' + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); + } +}; + diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts new file mode 100644 index 0000000..5c59926 --- /dev/null +++ b/src/routes/api/health/+server.ts @@ -0,0 +1,91 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { getSupabaseAdmin } from '$lib/supabaseAdmin'; +import { getSupabaseClient } from '$lib/supabaseClient'; + +type HealthStatus = { + status: 'ok' | 'degraded' | 'down'; + timestamp: string; + services: { + database: 'ok' | 'degraded' | 'down'; + bluesky?: 'ok' | 'degraded' | 'down'; + }; + version?: string; +}; + +/** + * GET /api/health + * Health check endpoint for monitoring and load balancers. + * Returns 200 if healthy, 503 if degraded/down. + */ +export const GET: RequestHandler = async () => { + const health: HealthStatus = { + status: 'ok', + timestamp: new Date().toISOString(), + services: { + database: 'ok' + }, + version: process.env.npm_package_version + }; + + let hasErrors = false; + + // Check database connectivity + try { + const admin = getSupabaseAdmin(); + if (admin) { + const { error } = await admin.from('users').select('id').limit(1); + if (error) { + health.services.database = 'degraded'; + hasErrors = true; + } + } else { + // Fallback to anon client + const client = getSupabaseClient(); + if (!client) { + health.services.database = 'down'; + health.status = 'degraded'; + hasErrors = true; + } + } + } catch { + health.services.database = 'down'; + health.status = 'down'; + hasErrors = true; + } + + // Check Bluesky connectivity (lightweight check) + try { + const { BskyAgent } = await import('@atproto/api'); + const { BSKY_APPVIEW_BASE } = await import('$lib/config/bsky'); + const base = BSKY_APPVIEW_BASE.endsWith('/xrpc') + ? BSKY_APPVIEW_BASE.slice(0, -('/xrpc'.length)) + : BSKY_APPVIEW_BASE; + const agent = new BskyAgent({ service: base }); + // Try a lightweight operation (this will fail fast if unreachable) + await (agent as any).getProfile?.({ actor: 'bsky.app' }).catch(() => { + throw new Error('Bluesky unreachable'); + }); + health.services.bluesky = 'ok'; + } catch { + health.services.bluesky = 'degraded'; + // Don't mark overall as down for Bluesky issues + } + + // Determine overall status + if (health.services.database === 'down') { + health.status = 'down'; + } else if (hasErrors || health.services.bluesky === 'degraded') { + health.status = 'degraded'; + } + + const statusCode = health.status === 'down' ? 503 : health.status === 'degraded' ? 200 : 200; + + return new Response(JSON.stringify(health, null, 2), { + status: statusCode, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, no-cache, must-revalidate' + } + }); +}; + diff --git a/src/routes/compare/[matchId]/+page.svelte b/src/routes/compare/[matchId]/+page.svelte index d721ab7..e6a4412 100644 --- a/src/routes/compare/[matchId]/+page.svelte +++ b/src/routes/compare/[matchId]/+page.svelte @@ -102,9 +102,15 @@

Accounts Used ({snapshot.platforms.bsky.accountsUsed.length})

+

+ These accounts contributed to the sentiment analysis for this {snapshot.window} window. + Learn more about account selection. +

{#each snapshot.platforms.bsky.accountsUsed as a} - @{a.handle}{a.displayName ? ` (${a.displayName})` : ''} + + @{a.handle}{a.displayName ? ` (${a.displayName})` : ''} + {/each}
diff --git a/src/routes/match/[id]/+page.svelte b/src/routes/match/[id]/+page.svelte index de509c1..159ecf7 100644 --- a/src/routes/match/[id]/+page.svelte +++ b/src/routes/match/[id]/+page.svelte @@ -91,12 +91,20 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' qs.set('liveMin', String(data.liveMin)); const res = await fetch(`/api/compare/${encodeURIComponent(matchId)}?${qs.toString()}`); if (!res.ok) { - const txt = await res.text().catch(() => ''); - throw new Error(txt || `Request failed: ${res.status}`); + let errorMessage = `Failed to load snapshot (${res.status})`; + try { + const errorData = await res.json(); + errorMessage = errorData.message || errorData.error || errorMessage; + } catch { + const txt = await res.text().catch(() => ''); + if (txt) errorMessage = txt; + } + throw new Error(errorMessage); } snapshot = await res.json(); } catch (e: any) { - snapError = e?.message || 'Snapshot failed'; + snapError = e?.message || 'Failed to load snapshot. Please try again.'; + console.error('Snapshot load error:', e); } finally { snapLoading = false; } @@ -125,6 +133,7 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' summaryLoading = true; summaryError = null; summaryText = null; + summaryMeta = null; try { const qs = new URLSearchParams({ matchId, @@ -135,8 +144,22 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' }); const res = await fetch(`/api/summaries/latest?${qs.toString()}`); if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err?.message || `Request failed: ${res.status}`); + let errorMessage = `Failed to load summary (${res.status})`; + try { + const err = await res.json(); + if (err.error === 'rate_limited') { + errorMessage = `Rate limit reached. Please wait ${Math.ceil((err.retryAfterMs || 60000) / 1000)} seconds before trying again.`; + } else if (err.error === 'openai_timeout') { + errorMessage = 'Summary generation timed out. Please try again.'; + } else if (err.error === 'missing_api_key') { + errorMessage = 'AI summary service is not configured.'; + } else { + errorMessage = err.message || err.error || errorMessage; + } + } catch { + // Use default error message + } + throw new Error(errorMessage); } const payload = await res.json(); summaryText = payload?.summary || '(No summary generated yet)'; @@ -146,7 +169,8 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' liveBin: payload?.liveBin ?? null }; } catch (e: any) { - summaryError = e?.message || 'Failed to load summary'; + summaryError = e?.message || 'Failed to load summary. Please try again.'; + console.error('Summary load error:', e); } finally { summaryLoading = false; } @@ -162,9 +186,14 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' .chips { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.35rem; } .chip { font-size: 0.75rem; padding: 0.15rem 0.45rem; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 999px; } .tabs { display: flex; gap: 0.5rem; } - .tab { padding: 0.35rem 0.7rem; border: 1px solid #e5e7eb; border-radius: 6px; background: #fafafa; cursor: pointer; } + .tab { padding: 0.35rem 0.7rem; border: 1px solid #e5e7eb; border-radius: 6px; background: #fafafa; cursor: pointer; transition: all 0.2s; } .tab.active { background: #eef2ff; border-color: #c7d2fe; } + .tab:hover:not(:disabled) { background: #f3f4f6; } + .tab:disabled { opacity: 0.6; cursor: not-allowed; } ul.msgs { max-height: 280px; overflow: auto; } + @keyframes spin { + to { transform: rotate(360deg); } + }
@@ -193,11 +222,17 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' {#if accountsUsedLive.length} -
+

Accounts Used (Live) ({accountsUsedLive.length})

+

+ These accounts are currently being monitored for live sentiment analysis. + Learn more. +

{#each accountsUsedLive as a} - @{a.handle}{a.displayName ? ` (${a.displayName})` : ''} + + @{a.handle}{a.displayName ? ` (${a.displayName})` : ''} + {/each}
@@ -214,7 +249,9 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient'
{#if snapError} -
Error: {snapError}
+
+ Error loading snapshot: {snapError} +
{:else if snapshot}
Generated: {snapshot.generatedAt} • Window: {snapshot.window.toUpperCase()} @@ -254,11 +291,17 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' {/if}
-
+

Accounts Used ({snapshot.platforms.bsky.accountsUsed.length})

+

+ These accounts contributed to the sentiment analysis for this {active} window. + Learn more about account selection. +

{#each snapshot.platforms.bsky.accountsUsed as a} - @{a.handle}{a.displayName ? ` (${a.displayName})` : ''} + + @{a.handle}{a.displayName ? ` (${a.displayName})` : ''} + {/each}
@@ -304,8 +347,15 @@ import { createSSEClient, type SSEClientController } from '$lib/utils/sseClient' {#if summaryOpen}
- {#if summaryError} -
Error: {summaryError}
+ {#if summaryLoading} +
+ + Generating summary... +
+ {:else if summaryError} +
+ Error: {summaryError} +
{:else if summaryText} {summaryText} {:else} diff --git a/src/routes/methodology/+page.svelte b/src/routes/methodology/+page.svelte index 1a38e0b..6b06cb3 100644 --- a/src/routes/methodology/+page.svelte +++ b/src/routes/methodology/+page.svelte @@ -42,11 +42,16 @@

Eligibility Rules

+

To ensure data quality and representativeness, accounts must meet minimum thresholds:

    -
  • Accounts must be established and active: minimum 500 followers and at least 36 months old.
  • -
  • Where platform APIs do not expose account creation date, we disclose the uncertainty and rely on followers/activity.
  • -
  • We prioritize more active accounts when selection caps apply.
  • +
  • Follower count: Minimum 500 followers (configurable per platform)
  • +
  • Account age: Minimum 6 months for Bluesky (36 months for Twitter/Threads when implemented)
  • +
  • Activity: Accounts are prioritized by follower count when selection caps apply
+

+ Note: Where platform APIs do not expose account creation date, we disclose the uncertainty and rely on followers/activity metrics. + Admin overrides can bypass eligibility requirements when needed for specific use cases. +

@@ -56,8 +61,18 @@ Precedence (higher first): match EXCLUDE → match INCLUDE → global EXCLUDE → global INCLUDE. Overrides may bypass eligibility and can expire automatically.

+

Account Selection Transparency

+

+ For full transparency, we display the list of accounts used in each analysis window: +

+
    +
  • Pre-match window: Accounts used are shown in snapshot views
  • +
  • Live window: Accounts are displayed in real-time as data streams in
  • +
  • Post-match window: Final account list is shown in snapshot views
  • +
  • AI Summaries: Include metadata about which accounts contributed to the summary
  • +

- Transparency: For public displays, we show a list of accounts used in each window. In live mode, Bluesky streams include the live accounts used. + All account lists show handles and display names (when available) so users can verify the sources of sentiment data.

diff --git a/supabase/migrations/005_create_users.sql b/supabase/migrations/005_create_users.sql new file mode 100644 index 0000000..c8fafa6 --- /dev/null +++ b/supabase/migrations/005_create_users.sql @@ -0,0 +1,55 @@ +-- Create users table for Bluesky-authenticated users +-- Links Bluesky handles/DIDs to app user accounts + +create table if not exists public.users ( + id uuid primary key default gen_random_uuid(), + bsky_handle text not null unique, + bsky_did text null, -- May be null initially, can be resolved later + display_name text null, + avatar_url text null, + verified_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Index for lookups by handle +create index if not exists idx_users_bsky_handle on public.users(bsky_handle); +create index if not exists idx_users_bsky_did on public.users(bsky_did) where bsky_did is not null; + +-- Enable RLS +alter table public.users enable row level security; + +-- Policy: Users can read their own profile +do $$ +begin + if not exists ( + select 1 from pg_policies p + where p.tablename = 'users' and p.policyname = 'users_select_own' + ) then + create policy users_select_own + on public.users + for select + using (true); -- Public read for now; can restrict later with auth.uid() + end if; +end$$; + +-- Policy: Service role can insert/update (for auth flow) +-- Note: Inserts/updates will be done via service role in verify-challenge endpoint + +-- Function to update updated_at timestamp +create or replace function update_updated_at_column() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +-- Trigger to auto-update updated_at +drop trigger if exists update_users_updated_at on public.users; +create trigger update_users_updated_at + before update on public.users + for each row + execute function update_updated_at_column(); + From fffeea354a61bce2b4082f8cebaf92a8aae02e69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Dec 2025 03:37:07 +0000 Subject: [PATCH 2/4] Add bugbot rules for authentication flow Co-authored-by: anitabansal.flights --- bugbot.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 bugbot.md diff --git a/bugbot.md b/bugbot.md new file mode 100644 index 0000000..1bfd666 --- /dev/null +++ b/bugbot.md @@ -0,0 +1,25 @@ +# Bugbot Rules + +This file contains rules and preferences for Cursor Bugbot to customize its analysis and avoid false positive bug reports. + +## Authentication Flow Rules + +### Challenge/Verification Code Retry Behavior + +**DO NOT** flag as a bug if verification challenges are cleared before user creation completes. + +**Rationale**: In this codebase, we intentionally clear verification challenges immediately upon verification, before attempting user creation. If user creation fails (e.g., due to database errors), users will need to request a new verification challenge and post a new code. This is an acceptable UX trade-off for this application. + +**Acceptable pattern**: +```typescript +// Clear challenge before user creation +await clearChallenge(handle); + +// Then attempt user creation +const user = await getOrCreateUser(handle); +if (!user) { + return error(500, 'Failed to create user'); +} +``` + +This pattern should NOT be flagged as requiring the challenge to be cleared only after successful user creation. From 7b54fd59d4c185de0b5c6c315ecd27a5fe61d373 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Dec 2025 03:48:18 +0000 Subject: [PATCH 3/4] Add file exclusion for database migrations Co-authored-by: anitabansal.flights --- bugbot.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bugbot.md b/bugbot.md index 1bfd666..0bb4413 100644 --- a/bugbot.md +++ b/bugbot.md @@ -23,3 +23,15 @@ if (!user) { ``` This pattern should NOT be flagged as requiring the challenge to be cleared only after successful user creation. + +## File Exclusions + +### Database Migration Files + +**DO NOT** analyze or report bugs in database migration files. + +**Paths to exclude**: +- `supabase/migrations/**/*.sql` +- Any file matching the pattern `**/migrations/**/*.sql` + +**Rationale**: Database migration files are intentionally written in a specific format and are version-controlled as historical records. They should not be modified after being applied to production databases. Any issues with migrations should be addressed through new migration files, not by flagging the existing ones. From 9f26122c0ecaf3310f33b58b6689ec21b747f0bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Dec 2025 04:01:47 +0000 Subject: [PATCH 4/4] feat: Add JS style guide compliance to src/ Co-authored-by: anitabansal.flights --- bugbot.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/bugbot.md b/bugbot.md index 0bb4413..c90bb2c 100644 --- a/bugbot.md +++ b/bugbot.md @@ -35,3 +35,23 @@ This pattern should NOT be flagged as requiring the challenge to be cleared only - Any file matching the pattern `**/migrations/**/*.sql` **Rationale**: Database migration files are intentionally written in a specific format and are version-controlled as historical records. They should not be modified after being applied to production databases. Any issues with migrations should be addressed through new migration files, not by flagging the existing ones. + +## Code Style Enforcement + +### JavaScript Style Guide Compliance + +**DO** check for code style compliance with standard JavaScript style guidelines for `.js` files in the `src/` folder. + +**Scope**: +- All `.js` files within `src/**/*.js` + +**Style guidelines to enforce**: +- Consistent indentation (2 or 4 spaces, not tabs) +- Semicolon usage (either always or never, consistently) +- Quote style (single or double quotes, used consistently) +- Proper function declaration formatting +- Consistent spacing around operators and keywords +- Proper use of ES6+ features where appropriate +- Consistent naming conventions (camelCase for variables/functions, PascalCase for classes) + +**Rationale**: Maintaining consistent code style in JavaScript files improves readability, reduces cognitive load during code reviews, and helps prevent subtle bugs. The `src/` folder contains the core application logic and should adhere to professional coding standards.