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
47 changes: 47 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Backend CI

on:
push:
paths:
- 'apps/backend/**'
- '.github/workflows/backend-ci.yml'
pull_request:
paths:
- 'apps/backend/**'
- '.github/workflows/backend-ci.yml'

jobs:
check:
name: Format · Lint · Test
runs-on: ubuntu-latest

defaults:
run:
working-directory: apps/backend

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install pnpm
uses: pnpm/action-setup@v4

- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: .

- name: Format check
run: pnpm format:check

- name: Lint
run: pnpm lint

- name: Tests
run: pnpm test
env:
JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-test-secret' }}
2 changes: 1 addition & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ jobs:
- name: Install dependencies
run: npm i -g pnpm && pnpm install
- name: Lint
run: pnpm run lint --if-present
run: pnpm run lint
7 changes: 7 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
17 changes: 17 additions & 0 deletions apps/backend/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// @ts-check
import js from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
ignores: ['dist/', 'node_modules/'],
},
);
14 changes: 12 additions & 2 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src",
"format:check": "prettier --check \"src/**/*.ts\"",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
Expand All @@ -30,14 +34,20 @@
"socket.io": "^4.8.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/node": "^20.19.37",
"@vitest/coverage-v8": "^4.1.6",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"prettier": "^3.8.3",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.3",
"vitest": "^4.1.6"
}
}
32 changes: 32 additions & 0 deletions apps/backend/src/__tests__/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { signToken, verifyToken } from '../lib/jwt.js';

describe('JWT utilities', () => {
const payload = { userId: 'user-123', walletAddress: 'GABCDE' };

it('signs a token without throwing', () => {
const token = signToken(payload);
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3);
});

it('verifies a valid token and returns the payload', () => {
const token = signToken(payload);
const decoded = verifyToken(token);
expect(decoded.userId).toBe(payload.userId);
expect(decoded.walletAddress).toBe(payload.walletAddress);
});

it('throws on a tampered token', () => {
const token = signToken(payload);
const tampered = token.slice(0, -4) + 'xxxx';
expect(() => verifyToken(tampered)).toThrow();
});

it('throws on an expired token', async () => {
const jwt = await import('jsonwebtoken');
const secret = process.env['JWT_SECRET']!;
const expired = jwt.default.sign(payload, secret, { expiresIn: -1 });
expect(() => verifyToken(expired)).toThrow(/expired/i);
});
});
53 changes: 53 additions & 0 deletions apps/backend/src/__tests__/nonce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createNonce, consumeNonce } from '../lib/nonce.js';

describe('Nonce store', () => {
const wallet = 'GABCDEFGHIJKLMNOP';

it('creates a 32-char hex nonce', () => {
const nonce = createNonce(wallet);
expect(nonce).toMatch(/^[0-9a-f]{32}$/);
});

it('consuming a valid nonce returns true', () => {
const nonce = createNonce(wallet);
expect(consumeNonce(wallet, nonce)).toBe(true);
});

it('consuming the same nonce twice returns false (single-use)', () => {
const nonce = createNonce(wallet);
consumeNonce(wallet, nonce);
expect(consumeNonce(wallet, nonce)).toBe(false);
});

it('consuming a wrong nonce returns false', () => {
createNonce(wallet);
expect(consumeNonce(wallet, 'wrong-nonce')).toBe(false);
});

it('consuming a nonce for an unknown wallet returns false', () => {
expect(consumeNonce('UNKNOWN_WALLET', 'any-nonce')).toBe(false);
});

describe('expiry', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it('rejects a nonce after 5 minutes have passed', () => {
const nonce = createNonce(wallet);
// Advance time past the 5-minute TTL
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(consumeNonce(wallet, nonce)).toBe(false);
});

it('accepts a nonce just before expiry', () => {
const nonce = createNonce(wallet);
vi.advanceTimersByTime(5 * 60 * 1000 - 1);
expect(consumeNonce(wallet, nonce)).toBe(true);
});
});
});
2 changes: 2 additions & 0 deletions apps/backend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Global test env vars — set before any module is imported
process.env['JWT_SECRET'] = 'test-secret-for-ci-only';
5 changes: 1 addition & 4 deletions apps/backend/src/middleware/socketAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ export interface AuthSocket extends Socket {
auth?: JwtPayload;
}

export function socketAuthMiddleware(
socket: AuthSocket,
next: (err?: Error) => void,
): void {
export function socketAuthMiddleware(socket: AuthSocket, next: (err?: Error) => void): void {
const token = socket.handshake.auth['token'] as string | undefined;

if (!token) {
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/routes/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => {
where: eq(conversationMembers.userId, userId),
with: {
conversation: {
with: { members: { with: { user: { columns: { id: true, username: true, avatarUrl: true } } } } },
with: {
members: { with: { user: { columns: { id: true, username: true, avatarUrl: true } } } },
},
},
},
});
Expand Down
86 changes: 41 additions & 45 deletions apps/backend/src/socket/messaging.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import type { Server } from 'socket.io';
import { and, eq, lt, desc } from 'drizzle-orm';
import { db } from '../db/index.js';
import {
conversations,
conversationMembers,
messages,
} from '../db/schema.js';
import { conversations, conversationMembers, messages } from '../db/schema.js';
import type { AuthSocket } from '../middleware/socketAuth.js';

const PAGE_SIZE = 30;
Expand Down Expand Up @@ -69,43 +65,43 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
// ── message_history ────────────────────────────────────────────────────────
// Payload: { conversationId: string; before?: string } (before = message id cursor)
// Returns the last PAGE_SIZE messages, optionally before a cursor for pagination.
socket.on(
'message_history',
async (payload: { conversationId: string; before?: string }) => {
const { conversationId, before } = payload;

const membership = await db.query.conversationMembers.findFirst({
where: and(
eq(conversationMembers.conversationId, conversationId),
eq(conversationMembers.userId, userId),
),
});
socket.on('message_history', async (payload: { conversationId: string; before?: string }) => {
const { conversationId, before } = payload;

if (!membership) {
socket.emit('error', { event: 'message_history', message: 'Not a member of this conversation' });
return;
}
const membership = await db.query.conversationMembers.findFirst({
where: and(
eq(conversationMembers.conversationId, conversationId),
eq(conversationMembers.userId, userId),
),
});

let cursor: Date | undefined;
if (before) {
const ref = await db.query.messages.findFirst({
where: eq(messages.id, before),
});
cursor = ref?.createdAt;
}
if (!membership) {
socket.emit('error', {
event: 'message_history',
message: 'Not a member of this conversation',
});
return;
}

const history = await db.query.messages.findMany({
where: cursor
? and(eq(messages.conversationId, conversationId), lt(messages.createdAt, cursor))
: eq(messages.conversationId, conversationId),
orderBy: desc(messages.createdAt),
limit: PAGE_SIZE,
with: { sender: { columns: { id: true, username: true, avatarUrl: true } } },
let cursor: Date | undefined;
if (before) {
const ref = await db.query.messages.findFirst({
where: eq(messages.id, before),
});
cursor = ref?.createdAt;
}

socket.emit('message_history', { conversationId, messages: history.reverse() });
},
);
const history = await db.query.messages.findMany({
where: cursor
? and(eq(messages.conversationId, conversationId), lt(messages.createdAt, cursor))
: eq(messages.conversationId, conversationId),
orderBy: desc(messages.createdAt),
limit: PAGE_SIZE,
with: { sender: { columns: { id: true, username: true, avatarUrl: true } } },
});

socket.emit('message_history', { conversationId, messages: history.reverse() });
});

// ── create_conversation ────────────────────────────────────────────────────
// Payload: { type: 'dm'|'group'; name?: string; memberIds: string[] }
Expand All @@ -117,19 +113,19 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void

const allMembers = Array.from(new Set([userId, ...memberIds]));

const [conversation] = await db
.insert(conversations)
.values({ type, name })
.returning();
const [conversation] = await db.insert(conversations).values({ type, name }).returning();

if (!conversation) {
socket.emit('error', { event: 'create_conversation', message: 'Failed to create conversation' });
socket.emit('error', {
event: 'create_conversation',
message: 'Failed to create conversation',
});
return;
}

await db.insert(conversationMembers).values(
allMembers.map((uid) => ({ conversationId: conversation.id, userId: uid })),
);
await db
.insert(conversationMembers)
.values(allMembers.map((uid) => ({ conversationId: conversation.id, userId: uid })));

socket.emit('conversation_created', conversation);
},
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./src/__tests__/setup.ts'],
},
});
Loading
Loading