Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ __pycache__/
target/
.soroban/
.DS_Store
.turbo/
.turbo/

ISSUES.md
IMPLEMENTATION_DOCS.md
7 changes: 3 additions & 4 deletions apps/ai_agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

import weaviate
from weaviate.classes.query import Filter

app = FastAPI(title="AI Agent API")

_SYSTEM_PROMPT = (
Expand Down Expand Up @@ -167,7 +170,6 @@ def summarise_proposal(request: ProposalSummariseRequest):
@app.post("/index/message")
def index_message(request: IndexMessageRequest):
try:
import weaviate
# Attempt connection to Weaviate
client = weaviate.connect_to_local()
except Exception as e:
Expand Down Expand Up @@ -218,7 +220,6 @@ def index_message(request: IndexMessageRequest):
@app.get("/search")
def search_messages(q: str, conversationId: str):
try:
import weaviate
client = weaviate.connect_to_local()
except Exception as e:
raise HTTPException(status_code=503, detail="Weaviate connection failed")
Expand All @@ -234,8 +235,6 @@ def search_messages(q: str, conversationId: str):
res = openai_client.embeddings.create(input=q, model="text-embedding-3-small")
vector = res.data[0].embedding

from weaviate.classes.query import Filter

results = collection.query.near_vector(
near_vector=vector,
limit=5,
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format:check": "prettier --check \"src/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\"",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/__tests__/auth.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,4 @@ describe('Auth rate limiting', () => {
expect(res.status).not.toBe(429);
}
});
});
});
2 changes: 2 additions & 0 deletions apps/backend/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ describe('loadEnv', () => {

const { DATABASE_URL: _omitted, ...withoutDbUrl } = validEnv;

const _ = _omitted; // eslint-disable-line @typescript-eslint/no-unused-vars

expect(() => loadEnv(withoutDbUrl)).toThrow('process.exit called');
expect(exitSpy).toHaveBeenCalledWith(1);

Expand Down
8 changes: 2 additions & 6 deletions apps/backend/src/__tests__/conversations.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe('GET /conversations — Redis caching', () => {
vi.clearAllMocks();
mockRedisInstance = { get: mockGet, setex: mockSetex, del: mockDel };
mockGroupBy.mockResolvedValue([]);
mockExecute.mockResolvedValue([]);
});

it('returns cached data without hitting DB on cache hit', async () => {
Expand All @@ -132,7 +133,6 @@ describe('GET /conversations — Redis caching', () => {

it('queries DB and writes to cache on cache miss', async () => {
mockGet.mockResolvedValue(null); // cache miss
const dbResult = [{ id: 'conv-2', type: 'group', messages: [], messageCount: 0 }];
mockFindMany.mockResolvedValue([
{ conversationId: 'conv-2', conversation: { id: 'conv-2', type: 'group', messages: [] } },
]);
Expand All @@ -142,11 +142,7 @@ describe('GET /conversations — Redis caching', () => {

expect(res.status).toBe(200);
expect(mockFindMany).toHaveBeenCalled();
expect(mockSetex).toHaveBeenCalledWith(
`conversations:${TEST_USER_ID}`,
30,
expect.any(String),
);
expect(mockSetex).toHaveBeenCalledWith(`conversations:${TEST_USER_ID}`, 30, expect.any(String));
});

it('falls back to DB when Redis is unavailable (redis is null)', async () => {
Expand Down
16 changes: 4 additions & 12 deletions apps/backend/src/__tests__/conversations.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,7 @@ describe('PATCH /conversations/:id', () => {
it('returns 400 for DM conversations', async () => {
mockFindConversation.mockResolvedValue({ id: 'conv-dm', type: 'dm' });

const res = await request(makeApp())
.patch('/conversations/conv-dm')
.send({ name: 'New Name' });
const res = await request(makeApp()).patch('/conversations/conv-dm').send({ name: 'New Name' });

expect(res.status).toBe(400);
expect(mockUpdateSet).not.toHaveBeenCalled();
Expand All @@ -311,18 +309,14 @@ describe('PATCH /conversations/:id', () => {
mockFindConversation.mockResolvedValue({ id: 'conv-1', type: 'group' });
mockFindMember.mockResolvedValue(undefined);

const res = await request(makeApp())
.patch('/conversations/conv-1')
.send({ name: 'New Name' });
const res = await request(makeApp()).patch('/conversations/conv-1').send({ name: 'New Name' });

expect(res.status).toBe(403);
expect(mockUpdateSet).not.toHaveBeenCalled();
});

it('returns 400 when neither name nor avatarUrl is provided', async () => {
const res = await request(makeApp())
.patch('/conversations/conv-1')
.send({});
const res = await request(makeApp()).patch('/conversations/conv-1').send({});

expect(res.status).toBe(400);
});
Expand All @@ -341,9 +335,7 @@ describe('PATCH /conversations/:id', () => {
mockUpdateReturning.mockResolvedValue([updatedConv]);
mockFindMany.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]);

const res = await request(makeApp())
.patch('/conversations/conv-1')
.send({ name: 'New Name' });
const res = await request(makeApp()).patch('/conversations/conv-1').send({ name: 'New Name' });

expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalled();
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MAX_MESSAGES_LIMIT = 50;
export const DEFAULT_MESSAGES_LIMIT = 30;
1 change: 0 additions & 1 deletion apps/backend/src/lib/nonce.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { randomBytes } from 'crypto';

// Nonces expire after 5 minutes
const TTL_MS = 5 * 60 * 1000;

const store = new Map<string, { nonce: string; expiresAt: number }>();
Expand Down
128 changes: 69 additions & 59 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,70 +37,80 @@ export const verifyLimiter: RateLimitRequestHandler = rateLimit({
});

// Step 1: client requests a challenge nonce for a wallet address
authRouter.post('/challenge', challengeLimiter, validate(ChallengeSchema), (req: Request, res: Response) => {
const { walletAddress } = req.body as ChallengeBody;

const nonce = createNonce(walletAddress);
const message = `Sign in to Clicked\nWallet: ${walletAddress}\nNonce: ${nonce}`;
authRouter.post(
'/challenge',
challengeLimiter,
validate(ChallengeSchema),
(req: Request, res: Response) => {
const { walletAddress } = req.body as ChallengeBody;

const nonce = createNonce(walletAddress);
const message = `Sign in to Clicked\nWallet: ${walletAddress}\nNonce: ${nonce}`;

res.json({ message, nonce });
});
res.json({ message, nonce });
},
);

// Step 2: client signs the message and submits the signature
authRouter.post('/verify', verifyLimiter, validate(VerifySchema), async (req: Request, res: Response) => {
const { walletAddress, signature, nonce } = req.body as VerifyBody;

// Validate and consume nonce
const valid = consumeNonce(walletAddress, nonce);
if (!valid) {
res.status(401).json({ error: 'Invalid or expired nonce' });
return;
}

// Verify Stellar keypair signature
try {
const message = `Sign in to Clicked\nWallet: ${walletAddress}\nNonce: ${nonce}`;
const rawMessageBytes = Buffer.from(message);
const freighterMessageBytes = createHash('sha256')
.update(`Stellar Signed Message:\n${message}`)
.digest();
const keypair = Keypair.fromPublicKey(walletAddress);
const hexSignatureBytes = Buffer.from(signature, 'hex');
const base64SignatureBytes = Buffer.from(signature, 'base64');

const isValidSignature =
keypair.verify(rawMessageBytes, hexSignatureBytes) ||
keypair.verify(freighterMessageBytes, base64SignatureBytes);

if (!isValidSignature) {
res.status(401).json({ error: 'Signature verification failed' });
authRouter.post(
'/verify',
verifyLimiter,
validate(VerifySchema),
async (req: Request, res: Response) => {
const { walletAddress, signature, nonce } = req.body as VerifyBody;

// Validate and consume nonce
const valid = consumeNonce(walletAddress, nonce);
if (!valid) {
res.status(401).json({ error: 'Invalid or expired nonce' });
return;
}
} catch {
res.status(401).json({ error: 'Invalid signature or wallet address' });
return;
}

// Upsert user + wallet
let userId: string;

const existingWallet = await db.query.wallets.findFirst({
where: eq(wallets.address, walletAddress),
with: { user: true },
});

if (existingWallet) {
userId = existingWallet.userId;
} else {
const [newUser] = await db.insert(users).values({}).returning({ id: users.id });
if (!newUser) {
res.status(500).json({ error: 'Failed to create user' });

// Verify Stellar keypair signature
try {
const message = `Sign in to Clicked\nWallet: ${walletAddress}\nNonce: ${nonce}`;
const rawMessageBytes = Buffer.from(message);
const freighterMessageBytes = createHash('sha256')
.update(`Stellar Signed Message:\n${message}`)
.digest();
const keypair = Keypair.fromPublicKey(walletAddress);
const hexSignatureBytes = Buffer.from(signature, 'hex');
const base64SignatureBytes = Buffer.from(signature, 'base64');

const isValidSignature =
keypair.verify(rawMessageBytes, hexSignatureBytes) ||
keypair.verify(freighterMessageBytes, base64SignatureBytes);

if (!isValidSignature) {
res.status(401).json({ error: 'Signature verification failed' });
return;
}
} catch {
res.status(401).json({ error: 'Invalid signature or wallet address' });
return;
}
userId = newUser.id;
await db.insert(wallets).values({ userId, address: walletAddress, isPrimary: true });
}

const token = signToken({ userId, walletAddress });
res.json({ token });
});
// Upsert user + wallet
let userId: string;

const existingWallet = await db.query.wallets.findFirst({
where: eq(wallets.address, walletAddress),
with: { user: true },
});

if (existingWallet) {
userId = existingWallet.userId;
} else {
const [newUser] = await db.insert(users).values({}).returning({ id: users.id });
if (!newUser) {
res.status(500).json({ error: 'Failed to create user' });
return;
}
userId = newUser.id;
await db.insert(wallets).values({ userId, address: walletAddress, isPrimary: true });
}

const token = signToken({ userId, walletAddress });
res.json({ token });
},
);
11 changes: 6 additions & 5 deletions apps/backend/src/routes/conversations.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Router } from 'express';
import type { IRouter } from 'express';
import { and, count, desc, eq, lt, sql, ne } from 'drizzle-orm';
import { asc, and, count, desc, eq, lt, sql, ne } from 'drizzle-orm';
import { db } from '../db/index.js';
import { conversationMembers, conversations, messages, tokenTransfers } from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
import { redis, CONV_CACHE_TTL, convCacheKey } from '../lib/redis.js';
import { invalidateConversationCaches } from '../lib/conversationCache.js';
import { serializeMessage } from '../lib/messages.js';
import { getSocketServer } from '../lib/socket.js';
import { MAX_MESSAGES_LIMIT, DEFAULT_MESSAGES_LIMIT } from '../constants.js';

export const conversationsRouter: IRouter = Router();

Expand Down Expand Up @@ -135,7 +136,10 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => {
FROM conversation_members cm
LEFT JOIN messages lrm ON lrm.id = cm.last_read_message_id
WHERE cm.user_id = ${userId}::uuid
AND cm.conversation_id = ANY(ARRAY[${sql.join(conversationIds.map((id) => sql`${id}::uuid`), sql`, `)}])
AND cm.conversation_id = ANY(ARRAY[${sql.join(
conversationIds.map((id) => sql`${id}::uuid`),
sql`, `,
)}])
`)),
]
: [];
Expand Down Expand Up @@ -416,9 +420,6 @@ conversationsRouter.patch('/:id', async (req: AuthRequest, res) => {
// #14 — GET /conversations/:id/messages
// Cursor-based pagination via ?before=<messageId>&limit=<n> (max 50).
// Returns messages in ascending order with a `nextCursor` field.
const MAX_MESSAGES_LIMIT = 50;
const DEFAULT_MESSAGES_LIMIT = 30;

conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => {
const userId = req.auth!.userId;
const conversationId = req.params['id'] as string | undefined;
Expand Down
Loading
Loading