initial code commit

This commit is contained in:
Deven Thiel 2026-03-04 21:21:59 -05:00
commit 27bb45f7df
56 changed files with 15106 additions and 0 deletions

14
server/.env.example Normal file
View file

@ -0,0 +1,14 @@
# Server
PORT=4000
CORS_ORIGIN=http://localhost:5173
# MongoDB
MONGO_URI=mongodb://localhost:27017
# JWT
JWT_SECRET=change-me-in-production-use-a-long-random-string
# Google OAuth (optional — email auth works without these)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/google/callback

38
server/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "ten99timecard-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"express": "^4.21.1",
"mongodb": "^6.10.0",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"@types/passport": "^1.0.16",
"@types/passport-google-oauth20": "^2.0.16",
"@types/cors": "^2.8.17",
"@types/node": "^22.9.0",
"@types/supertest": "^6.0.2",
"typescript": "^5.6.3",
"tsx": "^4.19.2",
"vitest": "^2.1.4",
"supertest": "^7.0.0",
"mongodb-memory-server": "^10.1.2"
}
}

View file

@ -0,0 +1,192 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { connect, disconnect, users, blobs } from '../db/mongo.js';
import { createApp } from '../app.js';
import { signToken, verifyToken } from '../middleware/auth.js';
let mongod: MongoMemoryServer;
let app: ReturnType<typeof createApp>;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await connect(mongod.getUri(), 'test');
app = createApp();
}, 60000);
afterAll(async () => {
await disconnect();
await mongod.stop();
});
beforeEach(async () => {
await users().deleteMany({});
await blobs().deleteMany({});
});
// ─── Health ──────────────────────────────────────────────────────────────────
describe('GET /api/health', () => {
it('returns ok', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
// ─── Auth ────────────────────────────────────────────────────────────────────
describe('POST /api/auth/signup', () => {
it('creates account and returns token', async () => {
const res = await request(app)
.post('/api/auth/signup')
.send({ email: 'test@example.com', password: 'longpassword' });
expect(res.status).toBe(201);
expect(res.body.token).toBeTruthy();
expect(res.body.email).toBe('test@example.com');
const payload = verifyToken(res.body.token);
expect(payload.email).toBe('test@example.com');
});
it('rejects duplicate email', async () => {
await request(app).post('/api/auth/signup').send({ email: 'dup@x.com', password: 'longpassword' });
const res = await request(app).post('/api/auth/signup').send({ email: 'dup@x.com', password: 'otherpass123' });
expect(res.status).toBe(409);
});
it('rejects invalid email', async () => {
const res = await request(app).post('/api/auth/signup').send({ email: 'not-an-email', password: 'longpassword' });
expect(res.status).toBe(400);
});
it('rejects short password', async () => {
const res = await request(app).post('/api/auth/signup').send({ email: 'x@y.com', password: 'short' });
expect(res.status).toBe(400);
});
it('hashes password (never stores plaintext)', async () => {
await request(app).post('/api/auth/signup').send({ email: 'hash@x.com', password: 'my-secret-password' });
const user = await users().findOne({ email: 'hash@x.com' });
expect(user?.passwordHash).toBeTruthy();
expect(user?.passwordHash).not.toContain('my-secret-password');
});
});
describe('POST /api/auth/login', () => {
it('succeeds with correct credentials', async () => {
await request(app).post('/api/auth/signup').send({ email: 'a@b.com', password: 'correctpass' });
const res = await request(app).post('/api/auth/login').send({ email: 'a@b.com', password: 'correctpass' });
expect(res.status).toBe(200);
expect(res.body.token).toBeTruthy();
});
it('fails with wrong password', async () => {
await request(app).post('/api/auth/signup').send({ email: 'a@b.com', password: 'correctpass' });
const res = await request(app).post('/api/auth/login').send({ email: 'a@b.com', password: 'wrongpassword' });
expect(res.status).toBe(401);
});
it('fails with unknown email', async () => {
const res = await request(app).post('/api/auth/login').send({ email: 'ghost@x.com', password: 'whateverpass' });
expect(res.status).toBe(401);
});
it('does not leak whether email exists', async () => {
await request(app).post('/api/auth/signup').send({ email: 'real@x.com', password: 'realpassword' });
const r1 = await request(app).post('/api/auth/login').send({ email: 'real@x.com', password: 'wrongggggg' });
const r2 = await request(app).post('/api/auth/login').send({ email: 'fake@x.com', password: 'wrongggggg' });
expect(r1.body.error).toBe(r2.body.error);
});
});
// ─── Data blob ───────────────────────────────────────────────────────────────
describe('/api/data', () => {
const getToken = async () => {
const r = await request(app).post('/api/auth/signup').send({ email: 'd@x.com', password: 'datapassword' });
return r.body.token as string;
};
it('rejects without auth', async () => {
const res = await request(app).get('/api/data');
expect(res.status).toBe(401);
});
it('rejects invalid token', async () => {
const res = await request(app).get('/api/data').set('Authorization', 'Bearer invalid.token.here');
expect(res.status).toBe(401);
});
it('404 when no blob saved yet', async () => {
const token = await getToken();
const res = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
it('PUT then GET returns blob', async () => {
const token = await getToken();
const blob = 'encrypted-base64-ciphertext-goes-here';
const put = await request(app)
.put('/api/data')
.set('Authorization', `Bearer ${token}`)
.send({ blob });
expect(put.status).toBe(200);
const get = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
expect(get.status).toBe(200);
expect(get.body.blob).toBe(blob);
});
it('PUT overwrites previous blob (upsert)', async () => {
const token = await getToken();
await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({ blob: 'v1' });
await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({ blob: 'v2' });
const get = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
expect(get.body.blob).toBe('v2');
});
it('DELETE removes blob', async () => {
const token = await getToken();
await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({ blob: 'x' });
await request(app).delete('/api/data').set('Authorization', `Bearer ${token}`);
const get = await request(app).get('/api/data').set('Authorization', `Bearer ${token}`);
expect(get.status).toBe(404);
});
it('isolates blobs per user', async () => {
const r1 = await request(app).post('/api/auth/signup').send({ email: 'u1@x.com', password: 'password1234' });
const r2 = await request(app).post('/api/auth/signup').send({ email: 'u2@x.com', password: 'password1234' });
await request(app).put('/api/data').set('Authorization', `Bearer ${r1.body.token}`).send({ blob: 'alice-blob' });
await request(app).put('/api/data').set('Authorization', `Bearer ${r2.body.token}`).send({ blob: 'bob-blob' });
const g1 = await request(app).get('/api/data').set('Authorization', `Bearer ${r1.body.token}`);
const g2 = await request(app).get('/api/data').set('Authorization', `Bearer ${r2.body.token}`);
expect(g1.body.blob).toBe('alice-blob');
expect(g2.body.blob).toBe('bob-blob');
});
it('PUT rejects missing blob', async () => {
const token = await getToken();
const res = await request(app).put('/api/data').set('Authorization', `Bearer ${token}`).send({});
expect(res.status).toBe(400);
});
});
// ─── JWT ─────────────────────────────────────────────────────────────────────
describe('JWT helpers', () => {
it('signs and verifies round-trip', () => {
const token = signToken({ userId: '123', email: 'x@y.com' });
const payload = verifyToken(token);
expect(payload.userId).toBe('123');
expect(payload.email).toBe('x@y.com');
});
it('rejects tampered token', () => {
const token = signToken({ userId: '123', email: 'x@y.com' });
const tampered = token.slice(0, -5) + 'XXXXX';
expect(() => verifyToken(tampered)).toThrow();
});
});

24
server/src/app.ts Normal file
View file

@ -0,0 +1,24 @@
import express from 'express';
import cors from 'cors';
import passport from 'passport';
import authRoutes, { configureGoogleStrategy } from './routes/auth.js';
import dataRoutes from './routes/data.js';
export function createApp() {
const app = express();
app.use(cors({ origin: process.env.CORS_ORIGIN || true }));
app.use(express.json({ limit: '5mb' }));
app.use(passport.initialize());
configureGoogleStrategy();
app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'ten99timecard-server' });
});
app.use('/api/auth', authRoutes);
app.use('/api/data', dataRoutes);
return app;
}

51
server/src/db/mongo.ts Normal file
View file

@ -0,0 +1,51 @@
import { MongoClient, Db, Collection } from 'mongodb';
export interface UserDoc {
_id?: string;
email: string;
/** bcrypt hash — only for email-auth users */
passwordHash?: string;
/** Google profile id — only for oauth users */
googleId?: string;
createdAt: Date;
}
/**
* The user's encrypted app-state blob. We NEVER see plaintext on the server
* the client encrypts with the user's local password before PUT-ing.
*/
export interface BlobDoc {
_id?: string;
userId: string;
blob: string;
updatedAt: Date;
}
let client: MongoClient | null = null;
let db: Db | null = null;
export async function connect(uri: string, dbName = 'ten99timecard'): Promise<Db> {
client = new MongoClient(uri);
await client.connect();
db = client.db(dbName);
await db.collection<UserDoc>('users').createIndex({ email: 1 }, { unique: true });
await db.collection<UserDoc>('users').createIndex({ googleId: 1 }, { sparse: true });
await db.collection<BlobDoc>('blobs').createIndex({ userId: 1 }, { unique: true });
return db;
}
export async function disconnect(): Promise<void> {
if (client) await client.close();
client = null;
db = null;
}
export function users(): Collection<UserDoc> {
if (!db) throw new Error('DB not connected');
return db.collection<UserDoc>('users');
}
export function blobs(): Collection<BlobDoc> {
if (!db) throw new Error('DB not connected');
return db.collection<BlobDoc>('blobs');
}

21
server/src/index.ts Normal file
View file

@ -0,0 +1,21 @@
import 'dotenv/config';
import { createApp } from './app.js';
import { connect } from './db/mongo.js';
const PORT = Number(process.env.PORT) || 4000;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017';
async function main() {
await connect(MONGO_URI);
console.log('✓ MongoDB connected');
const app = createApp();
app.listen(PORT, () => {
console.log(`✓ ten99timecard-server listening on :${PORT}`);
});
}
main().catch((err) => {
console.error('Startup failed:', err);
process.exit(1);
});

View file

@ -0,0 +1,39 @@
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const JWT_EXPIRY = '30d';
export interface AuthPayload {
userId: string;
email: string;
}
export function signToken(payload: AuthPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
}
export function verifyToken(token: string): AuthPayload {
return jwt.verify(token, JWT_SECRET) as AuthPayload;
}
declare global {
namespace Express {
interface Request {
auth?: AuthPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
try {
req.auth = verifyToken(header.slice(7));
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}

135
server/src/routes/auth.ts Normal file
View file

@ -0,0 +1,135 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { z } from 'zod';
import { users } from '../db/mongo.js';
import { signToken } from '../middleware/auth.js';
const router = Router();
const BCRYPT_ROUNDS = 12;
const credsSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
// ─── Email signup ────────────────────────────────────────────────────────────
router.post('/signup', async (req, res) => {
const parsed = credsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid email or password too short (min 8)' });
}
const { email, password } = parsed.data;
const existing = await users().findOne({ email });
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const result = await users().insertOne({
email,
passwordHash,
createdAt: new Date(),
});
const token = signToken({ userId: result.insertedId.toString(), email });
res.status(201).json({ token, email });
});
// ─── Email login ─────────────────────────────────────────────────────────────
router.post('/login', async (req, res) => {
const parsed = credsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid credentials' });
}
const { email, password } = parsed.data;
const user = await users().findOne({ email });
if (!user || !user.passwordHash) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = signToken({ userId: user._id!.toString(), email });
res.json({ token, email });
});
// ─── Google OAuth ────────────────────────────────────────────────────────────
export function configureGoogleStrategy() {
const clientID = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const callbackURL = process.env.GOOGLE_CALLBACK_URL || '/api/auth/google/callback';
if (!clientID || !clientSecret) {
console.warn('⚠ Google OAuth not configured (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET missing)');
return;
}
passport.use(
new GoogleStrategy(
{ clientID, clientSecret, callbackURL },
async (_accessToken, _refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
if (!email) return done(new Error('No email from Google'));
let user = await users().findOne({ googleId: profile.id });
if (!user) {
// Try to link to existing email account
user = await users().findOne({ email });
if (user) {
await users().updateOne({ _id: user._id }, { $set: { googleId: profile.id } });
} else {
const result = await users().insertOne({
email,
googleId: profile.id,
createdAt: new Date(),
});
user = { _id: result.insertedId.toString(), email, googleId: profile.id, createdAt: new Date() };
}
}
done(null, { userId: user._id!.toString(), email });
} catch (err) {
done(err as Error);
}
},
),
);
}
router.get('/google', passport.authenticate('google', { scope: ['email', 'profile'], session: false }));
router.get(
'/google/callback',
passport.authenticate('google', { session: false, failureRedirect: '/?oauth=failed' }),
(req, res) => {
const payload = req.user as { userId: string; email: string };
const token = signToken(payload);
// Send token back to opener window via postMessage, then close popup
res.send(`
<!doctype html><html><body><script>
if (window.opener) {
window.opener.postMessage({
type: 't99-oauth',
token: ${JSON.stringify(token)},
email: ${JSON.stringify(payload.email)}
}, '*');
window.close();
} else {
document.body.innerText = 'Authenticated. You can close this window.';
}
</script></body></html>
`);
},
);
export default router;

39
server/src/routes/data.ts Normal file
View file

@ -0,0 +1,39 @@
import { Router } from 'express';
import { z } from 'zod';
import { blobs } from '../db/mongo.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/data — fetch encrypted blob
router.get('/', async (req, res) => {
const doc = await blobs().findOne({ userId: req.auth!.userId });
if (!doc) return res.status(404).json({ error: 'No data' });
res.json({ blob: doc.blob, updatedAt: doc.updatedAt });
});
// PUT /api/data — store encrypted blob
const putSchema = z.object({ blob: z.string().min(1) });
router.put('/', async (req, res) => {
const parsed = putSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Missing blob' });
}
await blobs().updateOne(
{ userId: req.auth!.userId },
{ $set: { blob: parsed.data.blob, updatedAt: new Date() } },
{ upsert: true },
);
res.json({ ok: true });
});
// DELETE /api/data — remove blob
router.delete('/', async (req, res) => {
await blobs().deleteOne({ userId: req.auth!.userId });
res.json({ ok: true });
});
export default router;

14
server/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}

9
server/vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
testTimeout: 30000,
},
});