Initial commit: Music Hub collaboration platform

Full-stack music production collaboration tool with:
- SvelteKit frontend with Design System (CSS vars, 8 shared components)
- Hono API with auth, projects, tracks, versions, comments
- PostgreSQL + Drizzle ORM (8 tables, roles, permissions)
- S3-compatible storage with presigned upload URLs
- wavesurfer.js audio player with waveform visualization
- A/B version comparison with synchronized playback
- Timestamped comments with threading and resolve workflow
- Magic Link authentication with Resend email integration
- Background audio processing (ffmpeg transcode + waveform peaks)
- Role-based access control (Owner, Engineers, Artist, Label, Management, Viewer)
- Toast notifications, skeleton loading, responsive layout
- Docker deployment setup (API + Web + Postgres)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-02 13:23:10 +02:00
commit e420ed198b
88 changed files with 7306 additions and 0 deletions

159
apps/api/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,159 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
import { eq } from 'drizzle-orm';
import { magicLinkSchema, verifyTokenSchema } from '@music-hub/shared';
import { users, magicLinks, sessions } from '@music-hub/db';
import { hashToken } from '../middleware/auth.js';
import { sendMagicLinkEmail } from '../services/email.js';
import type { AppEnv } from '../types.js';
export const authRoutes = new Hono<AppEnv>()
.post('/magic-link', zValidator('json', magicLinkSchema), async (c) => {
const { email } = c.req.valid('json');
const db = c.get('db');
const token = generateToken();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
await db.insert(magicLinks).values({
email,
token,
expiresAt,
});
await sendMagicLinkEmail(email, token);
return c.json({ message: 'Magic link sent' });
})
.post('/verify', zValidator('json', verifyTokenSchema), async (c) => {
const { token } = c.req.valid('json');
const db = c.get('db');
const [link] = await db
.select()
.from(magicLinks)
.where(eq(magicLinks.token, token))
.limit(1);
if (!link || link.expiresAt < new Date() || link.usedAt) {
return c.json({ error: 'Invalid or expired token' }, 400);
}
await db
.update(magicLinks)
.set({ usedAt: new Date() })
.where(eq(magicLinks.id, link.id));
// Find or create user
let [user] = await db
.select()
.from(users)
.where(eq(users.email, link.email))
.limit(1);
if (!user) {
const name = link.email.split('@')[0];
[user] = await db
.insert(users)
.values({ email: link.email, name })
.returning();
}
// Create session
const sessionToken = generateToken();
const tokenHash = await hashToken(sessionToken);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await db.insert(sessions).values({
userId: user.id,
tokenHash,
expiresAt,
});
setCookie(c, 'session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 30 * 24 * 60 * 60,
});
return c.json({ user: { id: user.id, email: user.email, name: user.name } });
})
.post('/logout', async (c) => {
const sessionToken = getCookie(c, 'session');
if (sessionToken) {
const db = c.get('db');
const tokenHash = await hashToken(sessionToken);
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
}
deleteCookie(c, 'session');
return c.json({ message: 'Logged out' });
})
.get('/me', async (c) => {
const sessionToken = getCookie(c, 'session');
if (!sessionToken) {
return c.json({ user: null });
}
const db = c.get('db');
const tokenHash = await hashToken(sessionToken);
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
if (!session || session.expiresAt < new Date()) {
return c.json({ user: null });
}
const [user] = await db
.select({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl })
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
return c.json({ user: user || null });
})
.patch('/me', async (c) => {
const sessionToken = getCookie(c, 'session');
if (!sessionToken) return c.json({ error: 'Unauthorized' }, 401);
const db = c.get('db');
const tokenHash = await hashToken(sessionToken);
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
if (!session || session.expiresAt < new Date()) {
return c.json({ error: 'Unauthorized' }, 401);
}
const body = await c.req.json<{ name?: string }>();
if (!body.name?.trim()) return c.json({ error: 'Name is required' }, 400);
const [user] = await db
.update(users)
.set({ name: body.name.trim(), updatedAt: new Date() })
.where(eq(users.id, session.userId))
.returning({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl });
return c.json({ user });
});
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}