initial code commit
This commit is contained in:
commit
27bb45f7df
56 changed files with 15106 additions and 0 deletions
14
server/.env.example
Normal file
14
server/.env.example
Normal 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
38
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
192
server/src/__tests__/server.test.ts
Normal file
192
server/src/__tests__/server.test.ts
Normal 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
24
server/src/app.ts
Normal 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
51
server/src/db/mongo.ts
Normal 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
21
server/src/index.ts
Normal 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);
|
||||
});
|
||||
39
server/src/middleware/auth.ts
Normal file
39
server/src/middleware/auth.ts
Normal 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
135
server/src/routes/auth.ts
Normal 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
39
server/src/routes/data.ts
Normal 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
14
server/tsconfig.json
Normal 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
9
server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue