Add auth system with invite-only registration and admin roles

JWT-based auth (hono/jwt + bcrypt), anonymous-first flow preserved.
Registration requires invite code when REQUIRE_INVITE=true. Admin
user seeded on startup (admin/admin, forced password reset). Login
accepts email or username. Admin invitations management page in
sidebar. Regular users get invite-a-friend button when USER_INVITATIONS > 0.
Frontend gate screen blocks game access for unregistered users with
invite code entry, registration, login, and password reset flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 19:25:16 -04:00
parent df01ac8e35
commit 4881907c28
20 changed files with 1161 additions and 48 deletions
+12
View File
@@ -3,8 +3,11 @@ import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from '
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
username: text('username').unique(),
email: text('email').unique(),
passwordHash: text('password_hash'),
role: text('role').notNull().default('user'),
mustResetPassword: boolean('must_reset_password').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
});
@@ -44,3 +47,12 @@ export const achievements = pgTable('achievements', {
}, (table) => [
index('achievements_user_id_idx').on(table.userId),
]);
export const invitations = pgTable('invitations', {
id: uuid('id').defaultRandom().primaryKey(),
code: text('code').notNull().unique(),
createdBy: uuid('created_by').notNull().references(() => users.id),
usedBy: uuid('used_by').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at'),
});
+27
View File
@@ -0,0 +1,27 @@
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { db } from './index';
import { users } from './schema';
export async function seedAdmin() {
const [existing] = await db
.select()
.from(users)
.where(eq(users.username, 'admin'))
.limit(1);
if (existing) {
console.log('Admin user already exists');
return;
}
const passwordHash = await bcrypt.hash('admin', 10);
await db.insert(users).values({
username: 'admin',
passwordHash,
role: 'admin',
mustResetPassword: true,
});
console.log('Admin user seeded (admin/admin — password reset required)');
}
+15
View File
@@ -5,6 +5,13 @@ import { serve } from '@hono/node-server';
import { auth } from './routes/auth';
import { savesRouter } from './routes/saves';
import { leaderboardRouter } from './routes/leaderboard';
import { invitesRouter } from './routes/invites';
import { seedAdmin } from './db/seed';
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is required');
process.exit(1);
}
const app = new Hono();
@@ -19,12 +26,20 @@ app.use('*', cors({
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
app.get('/api/config', (c) => c.json({
requireInvite: process.env.REQUIRE_INVITE !== 'false',
userInvitations: parseInt(process.env.USER_INVITATIONS || '0', 10),
}));
app.route('/api/auth', auth);
app.route('/api/saves', savesRouter);
app.route('/api/leaderboard', leaderboardRouter);
app.route('/api/invites', invitesRouter);
const port = Number(process.env.PORT) || 3001;
console.log(`AI Tycoon API server starting on port ${port}...`);
await seedAdmin();
serve({ fetch: app.fetch, port });
+40
View File
@@ -0,0 +1,40 @@
import { sign, verify } from 'hono/jwt';
const JWT_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
export function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET env var is required');
return secret;
}
export async function createToken(
userId: string,
email: string | null,
role: string,
username: string | null,
mustResetPassword: boolean,
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return sign(
{ sub: userId, email, role, username, mustResetPassword, iat: now, exp: now + JWT_EXPIRY_SECONDS },
getJwtSecret(),
);
}
export async function verifyToken(token: string): Promise<{
sub: string;
email: string | null;
role: string;
username: string | null;
mustResetPassword: boolean;
}> {
const payload = await verify(token, getJwtSecret(), 'HS256');
return {
sub: payload.sub as string,
email: (payload.email as string) ?? null,
role: (payload.role as string) ?? 'user',
username: (payload.username as string) ?? null,
mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
};
}
+21 -3
View File
@@ -2,6 +2,7 @@ import { createMiddleware } from 'hono/factory';
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../db/schema';
import { verifyToken } from '../lib/jwt';
import type { AppEnv } from '../types';
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
@@ -14,10 +15,12 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const token = authHeader.slice(7);
try {
const payload = await verifyToken(token);
const [user] = await db
.select()
.from(users)
.where(eq(users.anonToken, token))
.where(eq(users.id, payload.sub))
.limit(1);
if (!user) {
@@ -30,9 +33,24 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
.where(eq(users.id, user.id));
c.set('userId', user.id);
c.set('user', user as AppEnv['Variables']['user']);
c.set('user', {
id: user.id,
anonToken: user.anonToken,
username: user.username,
email: user.email,
role: user.role,
mustResetPassword: user.mustResetPassword,
});
await next();
} catch {
return c.json({ error: 'Authentication failed' }, 500);
return c.json({ error: 'Invalid or expired token' }, 401);
}
});
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
const user = c.get('user');
if (user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
await next();
});
+135 -34
View File
@@ -1,7 +1,10 @@
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { eq, or } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { db } from '../db';
import { users } from '../db/schema';
import { createToken } from '../lib/jwt';
import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types';
const auth = new Hono<AppEnv>();
@@ -12,20 +15,51 @@ auth.post('/anonymous', async (c) => {
.values({})
.returning();
return c.json({
userId: user.id,
token: user.anonToken,
});
const token = await createToken(user.id, null, 'user', null, false);
return c.json({ userId: user.id, token });
});
auth.post('/link-email', async (c) => {
const userId = c.get('userId') as string;
if (!userId) return c.json({ error: 'Not authenticated' }, 401);
auth.post('/register', authMiddleware, async (c) => {
const userId = c.get('userId');
const { email, password, inviteCode } = await c.req.json<{
email: string;
password: string;
inviteCode: string;
}>();
const { email, password } = await c.req.json<{ email: string; password: string }>();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return c.json({ error: 'Valid email required' }, 400);
}
if (!password || password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
if (!email || !password) {
return c.json({ error: 'Email and password required' }, 400);
if (process.env.REQUIRE_INVITE !== 'false') {
if (!inviteCode) {
return c.json({ error: 'Invite code required' }, 400);
}
const { invitations } = await import('../db/schema');
const { isNull, and, sql } = await import('drizzle-orm');
const [consumed] = await db
.update(invitations)
.set({ usedBy: userId })
.where(
and(
eq(invitations.code, inviteCode),
isNull(invitations.usedBy),
or(
isNull(invitations.expiresAt),
sql`${invitations.expiresAt} > NOW()`,
),
),
)
.returning();
if (!consumed) {
return c.json({ error: 'Invalid or used invite code' }, 400);
}
}
const existing = await db
@@ -38,45 +72,112 @@ auth.post('/link-email', async (c) => {
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('');
const passwordHash = await bcrypt.hash(password, 10);
await db
const [updated] = await db
.update(users)
.set({ email, passwordHash: hashHex })
.where(eq(users.id, userId));
.set({ email, passwordHash })
.where(eq(users.id, userId))
.returning();
return c.json({ success: true });
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false);
return c.json({ userId: updated.id, token });
});
auth.post('/login', async (c) => {
const { email, password } = await c.req.json<{ email: string; password: string }>();
const { login, password } = await c.req.json<{ login: 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('');
if (!login || !password) {
return c.json({ error: 'Login and password required' }, 400);
}
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.where(or(eq(users.email, login), eq(users.username, login)))
.limit(1);
if (!user || user.passwordHash !== hashHex) {
if (!user || !user.passwordHash) {
return c.json({ error: 'Invalid credentials' }, 401);
}
return c.json({
userId: user.id,
token: user.anonToken,
});
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return c.json({ error: 'Invalid credentials' }, 401);
}
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword);
return c.json({ userId: user.id, token });
});
auth.post('/change-password', authMiddleware, async (c) => {
const user = c.get('user');
const { currentPassword, newPassword } = await c.req.json<{
currentPassword?: string;
newPassword: string;
}>();
if (!newPassword || newPassword.length < 8) {
return c.json({ error: 'New password must be at least 8 characters' }, 400);
}
if (!user.mustResetPassword) {
if (!currentPassword) {
return c.json({ error: 'Current password required' }, 400);
}
const [dbUser] = await db
.select({ passwordHash: users.passwordHash })
.from(users)
.where(eq(users.id, user.id))
.limit(1);
if (!dbUser?.passwordHash) {
return c.json({ error: 'No password set' }, 400);
}
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
if (!valid) {
return c.json({ error: 'Current password is incorrect' }, 401);
}
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await db
.update(users)
.set({ passwordHash, mustResetPassword: false })
.where(eq(users.id, user.id));
const token = await createToken(user.id, user.email, user.role, user.username, false);
return c.json({ success: true, token });
});
auth.post('/change-username', authMiddleware, async (c) => {
const user = c.get('user');
if (user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
const { username } = await c.req.json<{ username: string }>();
if (!username || username.length < 2) {
return c.json({ error: 'Username must be at least 2 characters' }, 400);
}
const existing = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (existing.length > 0 && existing[0].id !== user.id) {
return c.json({ error: 'Username already taken' }, 409);
}
await db
.update(users)
.set({ username })
.where(eq(users.id, user.id));
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword);
return c.json({ success: true, token });
});
export { auth };
+144
View File
@@ -0,0 +1,144 @@
import { Hono } from 'hono';
import { eq, and, isNull, or, sql, count, desc } from 'drizzle-orm';
import crypto from 'node:crypto';
import { db } from '../db';
import { invitations, users } from '../db/schema';
import { authMiddleware, requireAdmin } from '../middleware/auth';
import type { AppEnv } from '../types';
const invitesRouter = new Hono<AppEnv>();
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
const bytes = crypto.randomBytes(8);
let code = '';
for (let i = 0; i < 8; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
}
invitesRouter.post('/', authMiddleware, async (c) => {
const user = c.get('user');
if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Must be registered to create invites' }, 403);
}
if (user.role !== 'admin') {
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
if (limit <= 0) {
return c.json({ error: 'You are not allowed to create invites' }, 403);
}
const [{ value: created }] = await db
.select({ value: count() })
.from(invitations)
.where(eq(invitations.createdBy, user.id));
if (created >= limit) {
return c.json({ error: 'Invite limit reached' }, 403);
}
}
for (let attempt = 0; attempt < 5; attempt++) {
const code = generateCode();
try {
await db.insert(invitations).values({ code, createdBy: user.id });
return c.json({ code });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : '';
if (message.includes('unique') || message.includes('duplicate')) continue;
throw e;
}
}
return c.json({ error: 'Failed to generate unique code' }, 500);
});
invitesRouter.get('/validate/:code', async (c) => {
const code = c.req.param('code');
const [invite] = await db
.select()
.from(invitations)
.where(
and(
eq(invitations.code, code),
isNull(invitations.usedBy),
or(
isNull(invitations.expiresAt),
sql`${invitations.expiresAt} > NOW()`,
),
),
)
.limit(1);
return c.json({ valid: !!invite });
});
invitesRouter.get('/remaining', authMiddleware, async (c) => {
const user = c.get('user');
if (user.role === 'admin') {
return c.json({ remaining: -1 });
}
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
if (limit <= 0) {
return c.json({ remaining: 0 });
}
const [{ value: created }] = await db
.select({ value: count() })
.from(invitations)
.where(eq(invitations.createdBy, user.id));
return c.json({ remaining: Math.max(0, limit - created) });
});
invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
const allInvites = await db
.select({
id: invitations.id,
code: invitations.code,
createdBy: invitations.createdBy,
usedBy: invitations.usedBy,
createdAt: invitations.createdAt,
expiresAt: invitations.expiresAt,
})
.from(invitations)
.orderBy(desc(invitations.createdAt));
const userIds = new Set<string>();
for (const inv of allInvites) {
userIds.add(inv.createdBy);
if (inv.usedBy) userIds.add(inv.usedBy);
}
const userMap = new Map<string, { username: string | null; email: string | null }>();
if (userIds.size > 0) {
const userList = await db
.select({ id: users.id, username: users.username, email: users.email })
.from(users)
.where(or(...[...userIds].map(id => eq(users.id, id))));
for (const u of userList) {
userMap.set(u.id, { username: u.username, email: u.email });
}
}
const enriched = allInvites.map(inv => ({
id: inv.id,
code: inv.code,
createdBy: userMap.get(inv.createdBy) ?? { username: null, email: null },
usedBy: inv.usedBy ? (userMap.get(inv.usedBy) ?? { username: null, email: null }) : null,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
used: !!inv.usedBy,
}));
return c.json({ invitations: enriched });
});
export { invitesRouter };
+3
View File
@@ -4,7 +4,10 @@ export type AppEnv = {
user: {
id: string;
anonToken: string;
username: string | null;
email: string | null;
role: string;
mustResetPassword: boolean;
};
};
};