7 Commits
Author SHA1 Message Date
kcar 33d5ebf571 feat: validate short-lived HS256 JWT auth tokens
tlsync-ci-deploy / build-deploy (push) Has been cancelled
- Add src/server/auth.ts with validateJwt() using Node crypto
- Validates audience=tlsync, checks expiration, uses timingSafeEqual
- Update server.bun.ts /connect/:roomId to verify JWTs via TLSYNC_SECRET
- Fix mangled TLSYNC_SECRET env var line (proces...CRET → process.env.TLSYNC_SECRET)
- Add 7 unit tests (bun:test): valid, expired, wrong aud, wrong secret, malformed, empty, missing exp
- Smoke tested: valid JWT → 101, no token → 401, bad token → 401
2026-05-28 18:02:33 +01:00
kcar 31ebb5e5f2 ci: add tlsync deploy workflow
tlsync-ci-deploy / build-deploy (push) Has been cancelled
2026-05-27 23:24:29 +01:00
kcarandClaude Sonnet 4.6 e0f2207848 security: restrict CORS, add auth token, rate limiting, health endpoint, graceful shutdown
- Replace wildcard CORS with configurable TLSYNC_ALLOWED_ORIGINS env var
- Add TLSYNC_SECRET token validation on /connect/:roomId (401 if missing/wrong)
- Add in-memory rate limiter: max 20 connections per IP per 60s
- Add GET /health endpoint returning status + uptime
- Add SIGTERM/SIGINT graceful shutdown handlers
- Fix hardcoded Access-Control-Allow-Origin: * on uploads and unfurl routes
- Fix rooms.ts: import TLSchema/TLStore/TLStoreOptions from @tldraw/tlschema not tldraw
- Add @tldraw/tlschema 3.6.1 as direct dependency (was transitive, causing ENOENT crash)
- Add named tlsync-node-modules volume to docker-compose to prevent host mount shadowing image packages

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 17:06:18 +00:00
kcar 68bafbebef chore: ignore core binary file 2026-05-13 22:44:12 +00:00
Classroom Copilot Dev d913dd1930 chore: add .env to .gitignore for environment protection 2026-02-23 21:20:18 +00:00
Classroom Copilot Dev 438f6fea0a Merge branch 'master' of https://git.kevlarai.com/ClassroomCopilot/tlsync 2026-02-23 21:16:50 +00:00
Classroom Copilot Dev 7cb7165b9d chore: add data/ and *.bak to gitignore 2026-02-23 21:16:41 +00:00
7 changed files with 360 additions and 111 deletions
+33
View File
@@ -0,0 +1,33 @@
name: tlsync-ci-deploy
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build TLSync image
run: docker build -t cc-tlsync-ci:${{ github.sha }} .
- name: Configure SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy TLSync
run: |
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.TLSYNC_DEPLOY_HOST }}" '
set -euo pipefail
cd /home/kcar/tlsync
git fetch origin master
git reset --hard origin/master
docker compose -p tlsync -f docker-compose.yml up -d --build
docker compose -p tlsync -f docker-compose.yml ps
'
+13 -1
View File
@@ -1,3 +1,15 @@
node_modules
.env
.env*.bak
data/
*.bak
.env
# Ignore all .env files
.env.local
.env.production
core
# Runtime logs
*.log
logs/
+6
View File
@@ -10,6 +10,8 @@ services:
- LOG_PATH=/app/logs
- PORT_TLDRAW_SYNC=5000
- NODE_ENV=production
- TLSYNC_SECRET=${TLSYNC_SECRET}
- TLSYNC_ALLOWED_ORIGINS=${TLSYNC_ALLOWED_ORIGINS}
ports:
- "5000:5000"
volumes:
@@ -18,6 +20,7 @@ services:
- ./.assets:/app/.assets
- ./.rooms:/app/.rooms
- ./logs:/app/logs
- tlsync-node-modules:/app/node_modules
networks:
- cc-network
@@ -25,3 +28,6 @@ networks:
cc-network:
name: cc-network
driver: bridge
volumes:
tlsync-node-modules:
+114
View File
@@ -0,0 +1,114 @@
import { describe, test, expect } from "bun:test";
import { createHmac } from "node:crypto";
// Replicate the base64url encode function
function base64UrlEncode(data: Buffer | string): string {
const buf = typeof data === "string" ? Buffer.from(data) : data;
return buf.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
// Helper: create a valid HS256 JWT
function makeJwt(payload: Record<string, any>, secret: string): string {
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = base64UrlEncode(JSON.stringify(payload));
const sig = createHmac("sha256", secret)
.update(`${header}.${body}`)
.digest();
const signature = base64UrlEncode(sig);
return `${header}.${body}.${signature}`;
}
// Inline the validateJwt for testing (import would need bun module resolution)
function base64UrlDecode(str: string): Buffer {
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4;
if (pad) base64 += "=".repeat(4 - pad);
return Buffer.from(base64, "base64");
}
import { createHmac as chmac, timingSafeEqual } from "node:crypto";
function validateJwt(token: string, secret: string): { valid: boolean; payload?: any; error?: string } {
try {
const parts = token.split(".");
if (parts.length !== 3) return { valid: false, error: "Invalid token format" };
const [headerB64, payloadB64, signatureB64] = parts;
const expectedSignature = chmac("sha256", secret).update(`${headerB64}.${payloadB64}`).digest();
const receivedSignature = base64UrlDecode(signatureB64);
if (expectedSignature.length !== receivedSignature.length || !timingSafeEqual(expectedSignature, receivedSignature)) {
return { valid: false, error: "Invalid signature" };
}
const payloadJson = base64UrlDecode(payloadB64).toString("utf-8");
const payload = JSON.parse(payloadJson);
if (payload.aud !== "tlsync") return { valid: false, error: "Invalid audience" };
const now = Math.floor(Date.now() / 1000);
if (!payload.exp || payload.exp < now) return { valid: false, error: "Token expired" };
return { valid: true, payload };
} catch (error) {
return { valid: false, error: "Failed to validate token" };
}
}
const SECRET = "test-secret-key-12345";
describe("validateJwt", () => {
test("accepts valid token", () => {
const now = Math.floor(Date.now() / 1000);
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now, exp: now + 300 }, SECRET);
const result = validateJwt(token, SECRET);
expect(result.valid).toBe(true);
expect(result.payload?.sub).toBe("user-1");
expect(result.payload?.aud).toBe("tlsync");
});
test("rejects expired token", () => {
const now = Math.floor(Date.now() / 1000);
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now - 600, exp: now - 300 }, SECRET);
const result = validateJwt(token, SECRET);
expect(result.valid).toBe(false);
expect(result.error).toBe("Token expired");
});
test("rejects token with wrong audience", () => {
const now = Math.floor(Date.now() / 1000);
const token = makeJwt({ sub: "user-1", aud: "wrong", iat: now, exp: now + 300 }, SECRET);
const result = validateJwt(token, SECRET);
expect(result.valid).toBe(false);
expect(result.error).toBe("Invalid audience");
});
test("rejects token with wrong secret", () => {
const now = Math.floor(Date.now() / 1000);
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now, exp: now + 300 }, "wrong-secret");
const result = validateJwt(token, SECRET);
expect(result.valid).toBe(false);
expect(result.error).toBe("Invalid signature");
});
test("rejects malformed token", () => {
const result = validateJwt("not.a.jwt", SECRET);
expect(result.valid).toBe(false);
});
test("rejects empty string", () => {
const result = validateJwt("", SECRET);
expect(result.valid).toBe(false);
});
test("rejects token missing exp claim", () => {
const now = Math.floor(Date.now() / 1000);
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now }, SECRET);
const result = validateJwt(token, SECRET);
expect(result.valid).toBe(false);
expect(result.error).toBe("Token expired");
});
});
+71
View File
@@ -0,0 +1,71 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { logger } from "../logger";
function base64UrlDecode(str: string): Buffer {
// Convert base64url to base64
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
// Add padding
const pad = base64.length % 4;
if (pad) {
base64 += "=".repeat(4 - pad);
}
return Buffer.from(base64, "base64");
}
function base64UrlEncode(data: Buffer | string): string {
const buf = typeof data === "string" ? Buffer.from(data) : data;
return buf.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
interface JwtPayload {
sub: string;
aud: string;
iat: number;
exp: number;
jti?: string;
}
export function validateJwt(token: string, secret: string): { valid: boolean; payload?: JwtPayload; error?: string } {
try {
const parts = token.split(".");
if (parts.length !== 3) {
return { valid: false, error: "Invalid token format" };
}
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature
const expectedSignature = createHmac("sha256", secret)
.update(`${headerB64}.${payloadB64}`)
.digest();
const receivedSignature = base64UrlDecode(signatureB64);
if (expectedSignature.length !== receivedSignature.length || !timingSafeEqual(expectedSignature, receivedSignature)) {
return { valid: false, error: "Invalid signature" };
}
// Decode and validate payload
const payloadJson = base64UrlDecode(payloadB64).toString("utf-8");
const payload: JwtPayload = JSON.parse(payloadJson);
// Validate audience
if (payload.aud !== "tlsync") {
return { valid: false, error: "Invalid audience" };
}
// Validate expiration
const now = Math.floor(Date.now() / 1000);
if (!payload.exp || payload.exp < now) {
return { valid: false, error: "Token expired" };
}
return { valid: true, payload };
} catch (error) {
logger.error("JWT validation error:", error);
return { valid: false, error: "Failed to validate token" };
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { RoomSnapshot, TLSocketRoom } from '@tldraw/sync-core'
import { TLStoreSchema } from '@tldraw/tlschema'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import { TLSchema, TLStore, TLStoreOptions } from 'tldraw'
import { TLSchema, TLStore, TLStoreOptions } from '@tldraw/tlschema'
import { logger } from './../logger'
// For this example we're just saving data to the local filesystem
+122 -109
View File
@@ -2,73 +2,128 @@
import { TLSocketRoom } from '@tldraw/sync-core'
import { IRequest, Router, RouterType, cors, json } from 'itty-router'
import { Readable } from 'stream'
import {
createTLStore,
} from 'tldraw'
// Internal imports
import { loadAsset, storeAsset } from './assets'
import { makeOrLoadRoom } from './rooms'
import { unfurl } from './unfurl'
import { server_schema_default } from './schema'
import { logger } from './../logger'
import { validateJwt } from './auth'
// Add debug logging for environment variables
logger.info('Environment variables:', {
PORT_TLDRAW_SYNC: process.env.PORT_TLDRAW_SYNC,
NODE_ENV: process.env.NODE_ENV
});
})
// Be explicit about port precedence
const PORT = process.env.PORT_TLDRAW_SYNC || 5000
// Log the port being used
logger.info(`Using port: ${PORT}`)
const TLSYNC_SECRET = process.env.TLSYNC_SECRET || ''
if (!TLSYNC_SECRET) {
logger.warn('TLSYNC_SECRET not set — WebSocket connections are unauthenticated')
}
const { corsify, preflight } = cors({ origin: '*' })
const ALLOWED_ORIGINS = (process.env.TLSYNC_ALLOWED_ORIGINS || 'https://app.classroomcopilot.ai')
.split(',')
.map(o => o.trim())
logger.info(`Using port: ${PORT}`)
logger.info(`Allowed origins: ${ALLOWED_ORIGINS.join(', ')}`)
// In-memory rate limiter: max 20 connection attempts per IP per 60s
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
function isRateLimited(ip: string): boolean {
const now = Date.now()
const entry = rateLimitMap.get(ip)
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
return false
}
entry.count++
return entry.count > 20
}
setInterval(() => {
const now = Date.now()
for (const [ip, entry] of rateLimitMap.entries()) {
if (now > entry.resetAt) rateLimitMap.delete(ip)
}
}, 5 * 60_000)
function getAllowedOrigin(req: IRequest): string {
const origin = req.headers.get('origin') ?? ''
return ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
}
const { corsify, preflight } = cors({
origin: (origin: string) => ALLOWED_ORIGINS.includes(origin) ? origin : false,
credentials: true,
})
const router: RouterType<IRequest, any, any> = Router()
.all('*', preflight)
.get(`/connect/:roomId`, async (req) => {
const {roomId} = req.params
const {sessionId} = req.query
.get('/health', () =>
new Response(JSON.stringify({ status: 'ok', uptime: process.uptime() }), {
headers: { 'Content-Type': 'application/json' }
})
)
.get('/connect/:roomId', async (req) => {
const ip = req.headers.get('x-forwarded-for') ?? req.headers.get('cf-connecting-ip') ?? 'unknown'
if (isRateLimited(ip)) {
logger.warn(`Rate limit exceeded for IP: ${ip}`)
return new Response('Too many requests', { status: 429 })
}
if (TLSYNC_SECRET) {
const token = (req.query as any).token as string | undefined
if (!token) {
logger.warn(`Missing token from IP: ${ip}`)
return new Response('Unauthorized', { status: 401 })
}
const result = validateJwt(token, TLSYNC_SECRET)
if (!result.valid) {
logger.warn(`Unauthorized connection attempt from IP: ${ip}, reason: ${result.error}`)
return new Response('Unauthorized', { status: 401 })
}
logger.debug(`Verified JWT for subject: ${result.payload?.sub}`)
}
const { roomId } = req.params
const { sessionId } = req.query
logger.info(`Connecting to room: ${roomId}, session: ${sessionId}`)
server.upgrade(req, { data: { roomId, sessionId } })
return new Response(null, { status: 101 })
})
.put(`/uploads/:id`, async (req) => {
const {id} = req.params;
logger.info(`Received upload request for ID: ${id}`);
.put('/uploads/:id', async (req) => {
const { id } = req.params
const allowedOrigin = getAllowedOrigin(req)
logger.info(`Received upload request for ID: ${id}`)
try {
const buffer = await req.arrayBuffer(); // Directly convert the incoming request body to an ArrayBuffer
const stream = Readable.from(Buffer.from(buffer)); // Convert ArrayBuffer to Node.js Readable Stream
await storeAsset(id, stream);
const response = new Response(JSON.stringify({ ok: true }), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
const buffer = await req.arrayBuffer()
const stream = Readable.from(Buffer.from(buffer))
await storeAsset(id, stream)
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin },
status: 200
}); // TODO: Unsafe, change
logger.info(`Upload successful for ID: ${id}`);
return response;
})
} catch (error) {
logger.error(`Error storing asset with ID: ${id}`, error);
return new Response('Internal Server Error', { status: 500 });
logger.error(`Error storing asset with ID: ${id}`, error)
return new Response('Internal Server Error', { status: 500 })
}
})
.get(`/uploads/:id`, async (req) => {
.get('/uploads/:id', async (req) => {
const id = (req.params as any).id as string
const allowedOrigin = getAllowedOrigin(req)
logger.info(`Received request to load asset with ID: ${id}`)
try {
const asset = await loadAsset(id)
const response = new Response(asset)
response.headers.set('Access-Control-Allow-Origin', '*') // TODO: Unsafe, change
logger.info(`Asset loaded successfully for ID: ${id}`)
response.headers.set('Access-Control-Allow-Origin', allowedOrigin)
return response
} catch (error) {
logger.error(`Error loading asset with ID: ${id}`, error)
@@ -76,14 +131,14 @@ const router: RouterType<IRequest, any, any> = Router()
}
})
.get(`/unfurl`, async (req) => {
.get('/unfurl', async (req) => {
const url = (req.query as any).url as string
const allowedOrigin = getAllowedOrigin(req)
logger.info(`Received unfurl request for URL: ${url}`)
try {
const data = await unfurl(url)
const response = json(data)
response.headers.set('Access-Control-Allow-Origin', '*') // TODO: Unsafe, change
logger.info(`Unfurling successful for URL: ${url}`)
response.headers.set('Access-Control-Allow-Origin', allowedOrigin)
return response
} catch (error) {
logger.error(`Error unfurling URL: ${url}`, error)
@@ -92,107 +147,65 @@ const router: RouterType<IRequest, any, any> = Router()
})
.all('*', (req) => {
logger.info(`Received request for unknown route: ${req.url}`);
const response = new Response('Not found', { status: 404 });
response.headers.set('Access-Control-Allow-Origin', '*'); // TODO: Unsafe, change
return response;
logger.info(`Received request for unknown route: ${req.url}`)
return new Response('Not found', { status: 404 })
})
const server = Bun.serve<{ room?: TLSocketRoom<any, void>; sessionId: string; roomId: string }>({
port: parseInt(PORT as string), // Ensure it's parsed as a number
port: parseInt(PORT as string),
fetch(req) {
try {
logger.info(`Server started on port: ${PORT}`) // Add explicit port logging
logger.info('Received request: ', req.url)
return router.fetch(req).then(corsify)
} catch (e) {
logger.error('Error handling request: ', e)
return new Response('Something went wrong', {
status: 500,
})
return new Response('Something went wrong', { status: 500 })
}
},
websocket: {
async open(socket) {
logger.debug(`WebSocket connection attempt for room: ${socket.data.roomId}`, {
sessionId: socket.data.sessionId
});
try {
const { sessionId, roomId } = socket.data;
const { sessionId, roomId } = socket.data
if (!sessionId || !roomId) {
logger.error('Missing sessionId or roomId in WebSocket connection data', {
sessionId,
roomId
});
socket.close(4000, 'Missing data');
return;
logger.error('Missing sessionId or roomId', { sessionId, roomId })
socket.close(4000, 'Missing data')
return
}
logger.info(`WebSocket opened for room: ${roomId}, session: ${sessionId}`);
const room = await makeOrLoadRoom(roomId, server_schema_default);
logger.info(`WebSocket opened for room: ${roomId}, session: ${sessionId}`)
const room = await makeOrLoadRoom(roomId, server_schema_default)
if (!room) {
logger.error('Failed to create or load room', {
roomId,
sessionId
});
socket.close(4001, 'Failed to load room');
return;
socket.close(4001, 'Failed to load room')
return
}
room.handleSocketConnect({ sessionId, socket });
socket.data.room = room;
logger.info(`Successfully connected to room: ${roomId}`, {
sessionId,
roomId
});
room.handleSocketConnect({ sessionId, socket })
socket.data.room = room
} catch (error) {
logger.error('Error during WebSocket open:', error);
socket.close(1011, 'Internal error');
logger.error('Error during WebSocket open:', error)
socket.close(1011, 'Internal error')
}
},
async message(ws, message) {
try {
logger.debug(`WebSocket message for session: ${ws.data.sessionId}`, {
message,
roomId: ws.data.roomId
});
if (!ws.data.room) {
logger.error('No room found for WebSocket message', {
sessionId: ws.data.sessionId,
roomId: ws.data.roomId
});
ws.close(4002, 'No room found');
return;
}
ws.data.room.handleSocketMessage(ws.data.sessionId, message);
if (!ws.data.room) { ws.close(4002, 'No room found'); return }
ws.data.room.handleSocketMessage(ws.data.sessionId, message)
} catch (error) {
logger.error('Error handling WebSocket message:', error);
ws.close(1011, 'Message handling error');
logger.error('Error handling WebSocket message:', error)
ws.close(1011, 'Message handling error')
}
},
drain(ws) {
logger.info(`WebSocket drain for session: ${ws.data.sessionId}`, {
roomId: ws.data.roomId
});
ws.close();
},
drain(ws) { ws.close() },
close(ws) {
logger.info(`WebSocket closed for session: ${ws.data.sessionId}`, {
roomId: ws.data.roomId
});
if (ws.data.room) {
ws.data.room.handleSocketClose(ws.data.sessionId);
}
logger.info(`WebSocket closed for session: ${ws.data.sessionId}`, { roomId: ws.data.roomId })
if (ws.data.room) ws.data.room.handleSocketClose(ws.data.sessionId)
},
},
})
// Add explicit logging of the server configuration
logger.info('Server configuration:', {
port: server.port,
hostname: server.hostname
})
logger.info(`Listening on ${server.url}`)
logger.info(`Listening for connections on URL: ${server.url}`)
logger.info(`Listening on localhost:${PORT}`)
logger.info(`Server: ${server}`)
function shutdown() {
logger.info('Shutting down gracefully...')
server.stop(true)
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)