Add Hono backend with PostgreSQL, auth, cloud saves, and leaderboard

Server app (apps/server) with Hono framework and Drizzle ORM:
- PostgreSQL schema: users, saves, leaderboard, achievements tables
- Anonymous auth with UUID tokens, optional email/password linking
- Cloud save API: list, get, upsert, delete with auto-save hook
- Leaderboard API: per-category rankings with score submission
- CORS configured for dev server ports
- Typed middleware with Hono env variables

Frontend cloud save integration:
- API client with auth token management in localStorage
- useCloudSave hook auto-saves every 300 ticks when authenticated
- Vite env type declarations for VITE_API_URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:35:18 -04:00
parent 8c9555bc08
commit 8ea6c771a1
16 changed files with 1289 additions and 9 deletions
+82
View File
@@ -0,0 +1,82 @@
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../db/schema';
import type { AppEnv } from '../types';
const auth = new Hono<AppEnv>();
auth.post('/anonymous', async (c) => {
const [user] = await db
.insert(users)
.values({})
.returning();
return c.json({
userId: user.id,
token: user.anonToken,
});
});
auth.post('/link-email', async (c) => {
const userId = c.get('userId') as string;
if (!userId) return c.json({ error: 'Not authenticated' }, 401);
const { email, password } = await c.req.json<{ email: string; password: string }>();
if (!email || !password) {
return c.json({ error: 'Email and password required' }, 400);
}
const existing = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing.length > 0) {
return c.json({ error: 'Email already in use' }, 409);
}
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
await db
.update(users)
.set({ email, passwordHash: hashHex })
.where(eq(users.id, userId));
return c.json({ success: true });
});
auth.post('/login', async (c) => {
const { email, password } = await c.req.json<{ email: string; password: string }>();
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || user.passwordHash !== hashHex) {
return c.json({ error: 'Invalid credentials' }, 401);
}
return c.json({
userId: user.id,
token: user.anonToken,
});
});
export { auth };