diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts
index 088e688..418ff04 100644
--- a/app/api/auth/callback/route.ts
+++ b/app/api/auth/callback/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getAuthEndpoints } from '@/lib/auth/atproto';
+import { consumeOAuthState } from '@/lib/auth/oauth-state';
import { mintSurrealJwt } from '@/lib/auth/jwt';
import { AtpAgent } from '@atproto/api';
@@ -12,27 +13,23 @@ export async function GET(request: NextRequest) {
const code = searchParams.get('code');
const state = searchParams.get('state');
- // Get temporary values from cookies
- const cookieStore = await cookies();
- const cookieState = cookieStore.get('atproto_oauth_state')?.value;
- const code_verifier = cookieStore.get('atproto_pkce_verifier')?.value;
- const pdsUrl = cookieStore.get('atproto_pds_url')?.value;
-
- // Clear temporary cookies
- cookieStore.delete('atproto_oauth_state');
- cookieStore.delete('atproto_pkce_verifier');
- cookieStore.delete('atproto_pds_url');
-
- // 1. Validate state (CSRF protection)
- if (!state || state !== cookieState) {
- return NextResponse.redirect(new URL('/login?error=Invalid state', request.url));
+ // 1. Validate state parameter
+ if (!state || !code) {
+ return NextResponse.redirect(new URL('/login?error=Missing OAuth parameters', request.url));
}
- // 2. Check for errors
- if (!code || !pdsUrl || !code_verifier) {
- return NextResponse.redirect(new URL('/login?error=Callback failed', request.url));
+ // 2. Retrieve OAuth state from database (this also deletes it for one-time use)
+ console.log('[OAuth Callback] Looking up state:', state);
+ const oauthState = await consumeOAuthState(state);
+ console.log('[OAuth Callback] Retrieved oauthState:', oauthState);
+
+ if (!oauthState) {
+ console.error('[OAuth Callback] Invalid or expired state:', state);
+ return NextResponse.redirect(new URL('/login?error=Invalid or expired state', request.url));
}
+ const { codeVerifier: code_verifier, pdsUrl } = oauthState;
+
try {
// 3. Get the PDS's token endpoint
const { tokenEndpoint } = await getAuthEndpoints(pdsUrl);
@@ -75,30 +72,36 @@ export async function GET(request: NextRequest) {
// 6. Mint OUR app's SurrealDB JWT
const surrealJwt = mintSurrealJwt(session.did, session.handle);
- // 7. Set the SurrealDB JWT in a secure cookie for our app
- cookieStore.set('ponderants-auth', surrealJwt, {
+ // 7. Create redirect response
+ const response = NextResponse.redirect(new URL('/chat', request.url));
+
+ // 8. Set the SurrealDB JWT in a secure cookie on the response
+ response.cookies.set('ponderants-auth', surrealJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
// Store the ATproto tokens for later use
- cookieStore.set('atproto_access_token', access_token, {
+ response.cookies.set('atproto_access_token', access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
maxAge: 60 * 60,
path: '/',
});
- cookieStore.set('atproto_refresh_token', refresh_token, {
+ response.cookies.set('atproto_refresh_token', refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
- // 8. Redirect to the main application
- return NextResponse.redirect(new URL('/chat', request.url));
+ // 9. Redirect to the main application
+ return response;
} catch (error) {
console.error('Auth callback error:', error);
return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url));
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 9f902f5..0b89f16 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
-import { cookies } from 'next/headers';
import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto';
+import { storeOAuthState } from '@/lib/auth/oauth-state';
import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client';
const CLIENT_ID = process.env.BLUESKY_CLIENT_ID;
@@ -30,11 +30,8 @@ export async function GET(request: NextRequest) {
const code_verifier = randomPKCECodeVerifier();
const code_challenge = await calculatePKCECodeChallenge(code_verifier);
- // 4. Store verifier and state in a temporary cookie
- const cookieStore = await cookies();
- cookieStore.set('atproto_oauth_state', state, { httpOnly: true, maxAge: 600 });
- cookieStore.set('atproto_pkce_verifier', code_verifier, { httpOnly: true, maxAge: 600 });
- cookieStore.set('atproto_pds_url', pdsUrl, { httpOnly: true, maxAge: 600 });
+ // 4. Store OAuth state in SurrealDB (not cookies, as they don't survive external redirects)
+ await storeOAuthState(state, code_verifier, pdsUrl);
// 5. Construct the authorization URL
const authUrl = new URL(authorizationEndpoint);
diff --git a/app/chat/page.tsx b/app/chat/page.tsx
new file mode 100644
index 0000000..4e0a14c
--- /dev/null
+++ b/app/chat/page.tsx
@@ -0,0 +1,30 @@
+import { redirect } from 'next/navigation';
+import { getCurrentUser } from '@/lib/auth/session';
+import { Center, Paper, Stack, Title, Text } from '@mantine/core';
+
+export default async function ChatPage() {
+ const user = await getCurrentUser();
+
+ // Redirect to login if not authenticated
+ if (!user) {
+ redirect('/login');
+ }
+
+ return (
+
+
+
+
+ Welcome to Ponderants
+
+
+ Logged in as: {user.handle}
+
+
+ DID: {user.did}
+
+
+
+
+ );
+}
diff --git a/db/schema.surql b/db/schema.surql
new file mode 100644
index 0000000..f3900e0
--- /dev/null
+++ b/db/schema.surql
@@ -0,0 +1,91 @@
+-- --------------------------------------------------
+-- Ponderants :: SurrealDB Schema
+-- --------------------------------------------------
+
+-- --------------------------------------------------
+-- Access Control (JWT)
+-- --------------------------------------------------
+
+-- Define the JWT access method. This tells SurrealDB to trust
+-- JWTs signed by our Next.js backend using the HS512 algorithm
+-- and the secret key provided in the environment.
+-- (Note: DEFINE TOKEN is deprecated as of 2.x)
+DEFINE ACCESS app_jwt
+ ON DATABASE
+ TYPE JWT
+ ALGORITHM HS512
+ KEY $env.SURREALDB_JWT_SECRET;
+
+-- --------------------------------------------------
+-- Table: user
+-- --------------------------------------------------
+
+-- Stores basic user information, cached from ATproto.
+DEFINE TABLE user SCHEMAFULL;
+
+-- The user's decentralized identifier (DID) is their primary key.
+DEFINE FIELD did ON TABLE user TYPE string
+ ASSERT $value != NONE;
+
+DEFINE FIELD handle ON TABLE user TYPE string;
+
+-- Ensure DIDs are unique.
+DEFINE INDEX user_did_idx ON TABLE user COLUMNS did UNIQUE;
+
+-- --------------------------------------------------
+-- Table: node
+-- --------------------------------------------------
+
+-- Stores a single "thought node." This is the cache record for
+-- the com.ponderants.node lexicon.
+DEFINE TABLE node SCHEMAFULL
+ -- THIS IS THE CORE SECURITY MODEL:
+ -- Users can only perform actions on nodes where the
+ -- node's 'user_did' field matches the 'did' claim
+ -- from their validated JWT ('$token.did').
+ PERMISSIONS
+ FOR select, create, update, delete
+ WHERE user_did = $token.did;
+
+-- Foreign key linking to the user table (via DID).
+DEFINE FIELD user_did ON TABLE node TYPE string
+ ASSERT $value != NONE;
+
+-- The canonical URI of the record on the ATproto PDS.
+DEFINE FIELD atp_uri ON TABLE node TYPE string;
+
+DEFINE FIELD title ON TABLE node TYPE string;
+DEFINE FIELD body ON TABLE node TYPE string;
+
+-- The AI-generated vector embedding for the 'body'.
+-- We use array for the vector.
+DEFINE FIELD embedding ON TABLE node TYPE array;
+
+-- The 3D coordinates calculated by UMAP.
+DEFINE FIELD coords_3d ON TABLE node TYPE array
+ -- Must be a 3-point array [x, y, z] or empty.
+ ASSERT $value = NONE OR array::len($value) = 3;
+
+-- Define the vector search index.
+-- We use MTREE (or HNSW) for high-performance k-NN search.
+-- The dimension (1536) MUST match the output of the
+-- 'gemini-embedding-001' model.
+DEFINE INDEX node_embedding_idx ON TABLE node FIELDS embedding MTREE DIMENSION 1536;
+
+-- --------------------------------------------------
+-- Relation: links_to
+-- --------------------------------------------------
+
+-- This is a graph edge table, relating (node)->(node).
+DEFINE TABLE links_to SCHEMAFULL
+ -- Security for graph edges: A user can only create/view/delete
+ -- links between two nodes that BOTH belong to them.
+ PERMISSIONS
+ FOR select, create, delete
+ WHERE
+ (SELECT user_did FROM $from) = $token.did
+ AND
+ (SELECT user_did FROM $to) = $token.did;
+
+-- (No fields needed, it's a simple relation)
+-- Example usage: RELATE (node:1)-[links_to]->(node:2);
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2ca3005
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,19 @@
+services:
+ surrealdb:
+ image: surrealdb/surrealdb:latest
+ ports:
+ - "8000:8000"
+ command:
+ - start
+ - --log
+ - trace
+ - --user
+ - root
+ - --pass
+ - root
+ - memory
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
diff --git a/lib/auth/jwt.ts b/lib/auth/jwt.ts
index 601a133..2636cd1 100644
--- a/lib/auth/jwt.ts
+++ b/lib/auth/jwt.ts
@@ -1,5 +1,14 @@
import jwt from 'jsonwebtoken';
+export interface UserSession {
+ did: string;
+ handle: string;
+ iss: string;
+ aud: string;
+ exp: number;
+ iat: number;
+}
+
/**
* Mints a new JWT for our application's session management.
* This token is what SurrealDB will validate.
@@ -34,3 +43,27 @@ export function mintSurrealJwt(did: string, handle: string): string {
return token;
}
+
+/**
+ * Verifies and decodes a JWT token.
+ *
+ * @param token - The JWT token to verify
+ * @returns The decoded user session, or null if invalid
+ */
+export function verifySurrealJwt(token: string): UserSession | null {
+ const secret = process.env.SURREALDB_JWT_SECRET;
+ if (!secret) {
+ throw new Error('SURREALDB_JWT_SECRET is not set in environment.');
+ }
+
+ try {
+ const decoded = jwt.verify(token, secret, {
+ algorithms: ['HS512'],
+ }) as UserSession;
+
+ return decoded;
+ } catch (error) {
+ console.error('JWT verification failed:', error);
+ return null;
+ }
+}
diff --git a/lib/auth/oauth-state.ts b/lib/auth/oauth-state.ts
new file mode 100644
index 0000000..f6e6706
--- /dev/null
+++ b/lib/auth/oauth-state.ts
@@ -0,0 +1,143 @@
+import Surreal from 'surrealdb';
+
+const SURREALDB_URL = process.env.SURREALDB_URL;
+const SURREALDB_NAMESPACE = process.env.SURREALDB_NS;
+const SURREALDB_DATABASE = process.env.SURREALDB_DB;
+const SURREALDB_USER = process.env.SURREALDB_USER;
+const SURREALDB_PASS = process.env.SURREALDB_PASS;
+
+if (!SURREALDB_URL || !SURREALDB_NAMESPACE || !SURREALDB_DATABASE || !SURREALDB_USER || !SURREALDB_PASS) {
+ throw new Error('SurrealDB configuration is missing. Please set SURREALDB_URL, SURREALDB_NS, SURREALDB_DB, SURREALDB_USER, and SURREALDB_PASS in .env');
+}
+
+interface OAuthState {
+ state: string;
+ code_verifier: string;
+ pds_url: string;
+ created_at: string;
+}
+
+/**
+ * Gets a SurrealDB connection for OAuth state management.
+ * This uses a separate, unauthenticated connection since OAuth happens before user auth.
+ */
+async function getDb() {
+ const db = new Surreal();
+ await db.connect(SURREALDB_URL);
+
+ // Sign in with root credentials for OAuth state management
+ await db.signin({
+ username: SURREALDB_USER,
+ password: SURREALDB_PASS,
+ });
+
+ await db.use({ namespace: SURREALDB_NAMESPACE, database: SURREALDB_DATABASE });
+ return db;
+}
+
+/**
+ * Stores OAuth state parameters in SurrealDB for the duration of the OAuth flow.
+ * The state is used as the record ID for easy lookup.
+ *
+ * @param state - The OAuth state parameter (CSRF token)
+ * @param codeVerifier - The PKCE code verifier
+ * @param pdsUrl - The user's PDS URL
+ */
+export async function storeOAuthState(
+ state: string,
+ codeVerifier: string,
+ pdsUrl: string
+): Promise {
+ const db = await getDb();
+
+ try {
+ // Store with a 10 minute TTL (OAuth flows should complete quickly)
+ const created_at = new Date().toISOString();
+ console.log('[OAuth State] Storing state:', state);
+
+ // Use CREATE with CONTENT to store all fields
+ const result = await db.create(`oauth_state:⟨${state}⟩`, {
+ state,
+ code_verifier: codeVerifier,
+ pds_url: pdsUrl,
+ created_at,
+ });
+ console.log('[OAuth State] Stored successfully:', JSON.stringify(result, null, 2));
+ } finally {
+ await db.close();
+ }
+}
+
+/**
+ * Retrieves and deletes OAuth state from SurrealDB.
+ * This ensures one-time use of the state parameter (security best practice).
+ *
+ * @param state - The OAuth state parameter to look up
+ * @returns The OAuth state data, or null if not found or expired
+ */
+export async function consumeOAuthState(
+ state: string
+): Promise<{ codeVerifier: string; pdsUrl: string } | null> {
+ const db = await getDb();
+
+ try {
+ console.log('[OAuth State] Retrieving state:', state);
+ // Retrieve the state by record ID
+ const selectResult = await db.select(`oauth_state:⟨${state}⟩`);
+ console.log('[OAuth State] Select result:', JSON.stringify(selectResult, null, 2));
+
+ // db.select() returns an array when selecting a specific record ID
+ const result = Array.isArray(selectResult) ? selectResult[0] : selectResult;
+ console.log('[OAuth State] Retrieved record:', JSON.stringify(result, null, 2));
+
+ if (!result) {
+ console.log('[OAuth State] No result found for state:', state);
+ return null;
+ }
+
+ // Check if expired (older than 10 minutes)
+ const createdAt = new Date(result.created_at);
+ const now = new Date();
+ const ageMinutes = (now.getTime() - createdAt.getTime()) / 1000 / 60;
+ console.log('[OAuth State] State age:', ageMinutes, 'minutes');
+
+ if (ageMinutes > 10) {
+ console.log('[OAuth State] State expired, deleting');
+ // Delete expired state
+ await db.delete(`oauth_state:⟨${state}⟩`);
+ return null;
+ }
+
+ // Delete the state (one-time use)
+ console.log('[OAuth State] Deleting state (one-time use)');
+ await db.delete(`oauth_state:⟨${state}⟩`);
+
+ return {
+ codeVerifier: result.code_verifier,
+ pdsUrl: result.pds_url,
+ };
+ } finally {
+ await db.close();
+ }
+}
+
+/**
+ * Cleans up expired OAuth states (should be called periodically).
+ * In production, this would be a cron job or scheduled task.
+ */
+export async function cleanupExpiredOAuthStates(): Promise {
+ const db = await getDb();
+
+ try {
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
+
+ const result = await db.query<[OAuthState[]]>(
+ 'DELETE FROM oauth_state WHERE created_at < $cutoff RETURN BEFORE',
+ { cutoff: tenMinutesAgo }
+ );
+
+ return result[0]?.length || 0;
+ } finally {
+ await db.close();
+ }
+}
diff --git a/lib/auth/session.ts b/lib/auth/session.ts
new file mode 100644
index 0000000..b8be2f6
--- /dev/null
+++ b/lib/auth/session.ts
@@ -0,0 +1,19 @@
+import { cookies } from 'next/headers';
+import { verifySurrealJwt, type UserSession } from './jwt';
+
+/**
+ * Gets the current authenticated user from the session cookie.
+ * This function should be called from Server Components or API routes.
+ *
+ * @returns The user session if authenticated, null otherwise
+ */
+export async function getCurrentUser(): Promise {
+ const cookieStore = await cookies();
+ const authCookie = cookieStore.get('ponderants-auth');
+
+ if (!authCookie?.value) {
+ return null;
+ }
+
+ return verifySurrealJwt(authCookie.value);
+}
diff --git a/magnitude.config.ts b/magnitude.config.ts
index cd4e1fb..f1ccb4a 100644
--- a/magnitude.config.ts
+++ b/magnitude.config.ts
@@ -4,4 +4,6 @@ export default {
url: 'http://localhost:3000',
// We will configure magnitude to find tests in this directory
tests: 'tests/magnitude/**/*.mag.ts',
+ // Run tests in headless mode to avoid window focus issues
+ headless: true,
};