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:
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
]);
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
userId: string;
|
||||
user: {
|
||||
id: string;
|
||||
anonToken: string;
|
||||
email: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ai-tycoon/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { api, getAuthToken, setAuthToken } from '@/lib/api';
|
||||
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
|
||||
|
||||
export function useCloudSave() {
|
||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const lastSaveTick = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyName) return;
|
||||
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) return;
|
||||
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
lastSaveTick.current = tickCount;
|
||||
|
||||
const state = useGameStore.getState();
|
||||
const { activePage, notifications, ...gameState } = state;
|
||||
|
||||
api.saves.put({
|
||||
companyName: state.meta.companyName,
|
||||
saveVersion: state.meta.saveVersion,
|
||||
gameData: gameState,
|
||||
tickCount: state.meta.tickCount,
|
||||
era: state.meta.currentEra,
|
||||
}).catch(() => {});
|
||||
}, [tickCount, companyName]);
|
||||
}
|
||||
|
||||
export async function ensureAuth(): Promise<string | null> {
|
||||
let token = getAuthToken();
|
||||
if (token) return token;
|
||||
|
||||
try {
|
||||
const result = await api.auth.anonymous();
|
||||
setAuthToken(result.token);
|
||||
return result.token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
let authToken: string | null = localStorage.getItem('ai-tycoon-auth-token');
|
||||
|
||||
export function setAuthToken(token: string) {
|
||||
authToken = token;
|
||||
localStorage.setItem('ai-tycoon-auth-token', token);
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
authToken = null;
|
||||
localStorage.removeItem('ai-tycoon-auth-token');
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
|
||||
login: (email: string, password: string) =>
|
||||
request<{ userId: string; token: string }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
linkEmail: (email: string, password: string) =>
|
||||
request<{ success: boolean }>('/api/auth/link-email', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
},
|
||||
saves: {
|
||||
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'),
|
||||
get: (id: string) => request<{ save: { id: string; gameData: unknown } }>(`/api/saves/${id}`),
|
||||
put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
|
||||
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
leaderboard: {
|
||||
get: (category: string) => request<{ entries: Array<{ companyName: string; score: number; era: string; tickCount: number }> }>(`/api/leaderboard/${category}`),
|
||||
submit: (data: { companyName: string; category: string; score: number; era: string; tickCount: number }) =>
|
||||
request<{ entry: unknown }>('/api/leaderboard', { method: 'POST', body: JSON.stringify(data) }),
|
||||
},
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"module": "ESNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
Generated
+758
-9
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user