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
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon',
},
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@ai-tycoon/server",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && tsx src/index.ts",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@ai-tycoon/shared": "workspace:*",
"@hono/node-server": "^1.13.8",
"drizzle-orm": "^0.44.2",
"hono": "^4.7.10",
"postgres": "^3.4.7",
"uuid": "^11.1.0"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"@types/node": "^25.6.0",
"drizzle-kit": "^0.31.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
export type Database = typeof db;
+46
View File
@@ -0,0 +1,46 @@
import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
email: text('email').unique(),
passwordHash: text('password_hash'),
createdAt: timestamp('created_at').defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
});
export const saves = pgTable('saves', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
companyName: text('company_name').notNull(),
saveVersion: integer('save_version').notNull(),
gameData: jsonb('game_data').notNull(),
tickCount: integer('tick_count').notNull().default(0),
era: text('era').notNull().default('startup'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
index('saves_user_id_idx').on(table.userId),
]);
export const leaderboard = pgTable('leaderboard', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
companyName: text('company_name').notNull(),
category: text('category').notNull(),
score: integer('score').notNull(),
era: text('era').notNull(),
tickCount: integer('tick_count').notNull(),
submittedAt: timestamp('submitted_at').defaultNow().notNull(),
}, (table) => [
index('leaderboard_category_score_idx').on(table.category, table.score),
]);
export const achievements = pgTable('achievements', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
achievementId: text('achievement_id').notNull(),
unlockedAt: timestamp('unlocked_at').defaultNow().notNull(),
}, (table) => [
index('achievements_user_id_idx').on(table.userId),
]);
+28
View File
@@ -0,0 +1,28 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serve } from '@hono/node-server';
import { auth } from './routes/auth';
import { savesRouter } from './routes/saves';
import { leaderboardRouter } from './routes/leaderboard';
const app = new Hono();
app.use('*', logger());
app.use('*', cors({
origin: ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175', 'http://localhost:5178'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
}));
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
app.route('/api/auth', auth);
app.route('/api/saves', savesRouter);
app.route('/api/leaderboard', leaderboardRouter);
const port = Number(process.env.PORT) || 3001;
console.log(`AI Tycoon API server starting on port ${port}...`);
serve({ fetch: app.fetch, port });
+38
View File
@@ -0,0 +1,38 @@
import { createMiddleware } from 'hono/factory';
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../db/schema';
import type { AppEnv } from '../types';
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Missing authorization token' }, 401);
}
const token = authHeader.slice(7);
try {
const [user] = await db
.select()
.from(users)
.where(eq(users.anonToken, token))
.limit(1);
if (!user) {
return c.json({ error: 'Invalid token' }, 401);
}
await db
.update(users)
.set({ lastSeenAt: new Date() })
.where(eq(users.id, user.id));
c.set('userId', user.id);
c.set('user', user as AppEnv['Variables']['user']);
await next();
} catch {
return c.json({ error: 'Authentication failed' }, 500);
}
});
+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 };
+49
View File
@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import { eq, desc } from 'drizzle-orm';
import { db } from '../db';
import { leaderboard } from '../db/schema';
import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types';
const leaderboardRouter = new Hono<AppEnv>();
leaderboardRouter.get('/:category', async (c) => {
const category = c.req.param('category');
const entries = await db
.select()
.from(leaderboard)
.where(eq(leaderboard.category, category))
.orderBy(desc(leaderboard.score))
.limit(50);
return c.json({ entries });
});
leaderboardRouter.post('/', authMiddleware, async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{
companyName: string;
category: string;
score: number;
era: string;
tickCount: number;
}>();
const [entry] = await db
.insert(leaderboard)
.values({
userId,
companyName: body.companyName,
category: body.category,
score: body.score,
era: body.era,
tickCount: body.tickCount,
})
.returning();
return c.json({ entry });
});
export { leaderboardRouter };
+107
View File
@@ -0,0 +1,107 @@
import { Hono } from 'hono';
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { saves } from '../db/schema';
import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types';
const savesRouter = new Hono<AppEnv>();
savesRouter.use('*', authMiddleware);
savesRouter.get('/', async (c) => {
const userId = c.get('userId') as string;
const userSaves = await db
.select({
id: saves.id,
companyName: saves.companyName,
era: saves.era,
tickCount: saves.tickCount,
updatedAt: saves.updatedAt,
})
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(10);
return c.json({ saves: userSaves });
});
savesRouter.get('/:id', async (c) => {
const userId = c.get('userId') as string;
const saveId = c.req.param('id');
const [save] = await db
.select()
.from(saves)
.where(and(eq(saves.id, saveId), eq(saves.userId, userId)))
.limit(1);
if (!save) {
return c.json({ error: 'Save not found' }, 404);
}
return c.json({ save });
});
savesRouter.put('/', async (c) => {
const userId = c.get('userId') as string;
const body = await c.req.json<{
companyName: string;
saveVersion: number;
gameData: unknown;
tickCount: number;
era: string;
}>();
const existing = await db
.select({ id: saves.id })
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(1);
if (existing.length > 0) {
await db
.update(saves)
.set({
companyName: body.companyName,
saveVersion: body.saveVersion,
gameData: body.gameData,
tickCount: body.tickCount,
era: body.era,
updatedAt: new Date(),
})
.where(eq(saves.id, existing[0].id));
return c.json({ id: existing[0].id, updated: true });
}
const [newSave] = await db
.insert(saves)
.values({
userId,
companyName: body.companyName,
saveVersion: body.saveVersion,
gameData: body.gameData,
tickCount: body.tickCount,
era: body.era,
})
.returning({ id: saves.id });
return c.json({ id: newSave.id, created: true });
});
savesRouter.delete('/:id', async (c) => {
const userId = c.get('userId') as string;
const saveId = c.req.param('id');
await db
.delete(saves)
.where(and(eq(saves.id, saveId), eq(saves.userId, userId)));
return c.json({ deleted: true });
});
export { savesRouter };
+10
View File
@@ -0,0 +1,10 @@
export type AppEnv = {
Variables: {
userId: string;
user: {
id: string;
anonToken: string;
email: string | null;
};
};
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ai-tycoon/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}