Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions bugbot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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.

## 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.

## 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.
42 changes: 42 additions & 0 deletions src/lib/auth/session.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Comment on lines +14 to +25

@coderabbitai coderabbitai Bot Dec 10, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

CRITICAL: Unsigned session cookies enable authentication bypass.

The session data is stored as plain JSON without any signing or encryption. An attacker can:

  1. Read their session cookie
  2. Modify the userId or handle fields
  3. Impersonate any user

This is a severe authentication vulnerability that allows complete account takeover.

Recommended solutions:

Option 1: Use signed cookies with SvelteKit's built-in signing

export function getSession(cookies: Cookies): SessionData | null {
  try {
    const sessionCookie = cookies.get('session', { signed: true });
    if (!sessionCookie) return null;
    const session = JSON.parse(sessionCookie) as SessionData;
    if (!session.userId || !session.handle || !session.verifiedAt) return null;
    return session;
  } catch {
    return null;
  }
}

Then in verify-challenge/+server.ts, set the cookie with signing:

cookies.set('session', JSON.stringify(sessionData), {
  path: '/',
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  maxAge: 60 * 60 * 24 * 30,
  signed: true  // Add this
});

Option 2: Use JWT tokens (as mentioned in the verify-challenge comment at line 71)

Option 3: Migrate to Supabase Auth for full session management

Note: If using SvelteKit's signed cookies, ensure you have a strong secret configured in your environment.

🤖 Prompt for AI Agents
In src/lib/auth/session.ts around lines 14 to 25, the session cookie is parsed
from plain JSON which allows tampering; update the code to read a signed cookie
(use cookies.get('session', { signed: true })) and ensure the cookie is set
elsewhere with signing enabled (cookies.set must include signed: true plus
httpOnly, secure, sameSite, and maxAge), or replace this scheme with JWTs or an
external auth provider; also verify a strong cookie secret is configured in your
environment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CodeRabbit ai - what are the pros and cons of the three options?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smb060606 Great question! Here's a detailed comparison of the three authentication approaches:

Option 1: SvelteKit Signed Cookies

Pros:

  • Minimal code changes - Just add signed: true to your existing cookie operations
  • Built-in to SvelteKit - No additional dependencies needed
  • Lightweight - No external auth service or token parsing overhead
  • Server-side control - Session data stored server-side, cookies just contain signed data
  • Quick to implement - Could fix the vulnerability in minutes

Cons:

  • Requires strong secret management - Must configure and protect a cookie signing secret
  • Limited scalability - Cookie size constraints (typically 4KB limit)
  • Session invalidation complexity - No built-in way to revoke sessions without additional infrastructure
  • Multi-server challenges - All servers need the same secret; rotation is tricky
  • Basic security - Signing prevents tampering but cookies are still readable (not encrypted)

Best for:

Single-server or small-scale applications needing a quick security fix.


Option 2: JWT Tokens

Pros:

  • Stateless - No server-side session storage needed
  • Scalable - Works seamlessly across multiple servers
  • Industry standard - Well-understood, many libraries available
  • Flexible - Can include custom claims, set expiration, support refresh tokens
  • Revocation possible - With short expiry + refresh token pattern or token blacklist

Cons:

  • More complex - Requires JWT library, token generation/validation logic, refresh token flow
  • Token size - JWTs are larger than simple session IDs, increasing bandwidth
  • Cannot revoke easily - Once issued, a JWT is valid until expiration (unless using blacklist)
  • More code to maintain - Need to implement token refresh, rotation, storage patterns
  • Clock synchronization - Expiry validation requires server time accuracy

Best for:

Multi-server deployments, API-first architectures, or when you need fine-grained control over authentication.


Option 3: Supabase Auth

Pros:

  • Fully managed - Supabase handles sessions, tokens, refresh, security best practices
  • Feature-rich - Built-in OAuth providers, magic links, password reset, email verification
  • Secure by default - Industry-standard JWT implementation with automatic refresh
  • Row-level security integration - Native integration with Supabase RLS policies
  • Less code - Offload authentication complexity to a battle-tested service
  • Multi-device support - Built-in session management across devices

Cons:

  • Significant refactoring - Would require rewriting your auth flow and data structures
  • Lock-in - Creates dependency on Supabase Auth service
  • Loss of custom flow - Your current Bluesky verification flow would need adaptation
  • Learning curve - Team needs to learn Supabase Auth patterns and APIs
  • Overkill? - May be more than needed if you just want Bluesky-only auth

Best for:

Greenfield projects, apps needing multiple auth providers, or when you want production-grade auth without maintaining it.


My Recommendation:

Given your current codebase and Bluesky-specific auth flow:

Short-term (immediate fix): Use Option 1 (signed cookies) to quickly patch the security vulnerability. It's a 10-minute fix that makes your current system secure.

Long-term (if scaling): Consider Option 2 (JWTs) if you expect to scale horizontally or build APIs. Consider Option 3 (Supabase Auth) if you want to add other auth providers (email, OAuth) or need enterprise-grade features.

For a simple Bluesky-authenticated app with one or two servers, signed cookies are perfectly adequate and maintainable.

Would you like me to help implement any of these options?


/**
* 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;
}

140 changes: 140 additions & 0 deletions src/lib/services/userService.ts
Original file line number Diff line number Diff line change
@@ -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<BskyAgent> | 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<BskyAgent> {
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<string | null> {
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<User | null> {
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<User | null> {
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
};
}

42 changes: 34 additions & 8 deletions src/routes/api/auth/bsky/create-challenge/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
);
}
Comment on lines +22 to 34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Strengthen handle validation to match Bluesky handle format.

The current validation only checks for the presence of '.' or '@', which is too permissive and would accept invalid handles like "a.b", "@x", or handles with special characters. Consider using a more robust validation pattern.

Apply this diff to add more thorough validation:

-    // Basic handle validation
-    if (!handle.includes('.') && !handle.includes('@')) {
+    // Validate handle format (domain-like structure or @-prefixed)
+    const handlePattern = /^@?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
+    if (!handlePattern.test(handle)) {
       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".'
+          message: 'Invalid Bluesky handle format. Handles must be valid domain names (e.g., "example.bsky.social") or prefixed with @ (e.g., "@example.bsky.social").'
         }),
🤖 Prompt for AI Agents
In src/routes/api/auth/bsky/create-challenge/+server.ts around lines 22-34, the
current check only looks for '.' or '@' and is too permissive; replace it with
stricter validation that (1) trims the input and optionally strips a leading
'@', (2) ensures the local username part is 2–64 characters long and contains
only letters, digits, underscores or hyphens, (3) ensures the host part contains
at least two dot-separated labels, each label only letters/digits/hyphens and
the final label is at least 2 characters long, and (4) returns the same 400 JSON
response when validation fails; implement this using a single
regular-expression-based test or equivalent segmented checks and normalize the
handle (e.g., toLowerCase) before further processing.


// If an existing challenge exists and hasn't expired, reuse it (avoid generating multiple codes quickly)
Expand All @@ -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' }
}
);
}
};
Loading
Loading