Ship Novya PWA with backend for deployable production use.
Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { Hono } from 'hono';
|
||||
import { jsonValidator } from '../../shared/validation/index.js';
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
import { env } from '../../config/index.js';
|
||||
import { getClientIp } from '../../lib/clientIp.js';
|
||||
import { created, success } from '../../lib/response.js';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { loginSchema, registerSchema } from './auth.schemas.js';
|
||||
import {
|
||||
createGuest,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
toPublicUser,
|
||||
} from './auth.service.js';
|
||||
import { prisma } from '../../lib/prisma.js';
|
||||
|
||||
const authAbuseLimiter = rateLimiter({
|
||||
windowMs: env.RATE_LIMIT_WINDOW_MS,
|
||||
limit: env.AUTH_RATE_LIMIT_MAX,
|
||||
standardHeaders: 'draft-6',
|
||||
keyGenerator: (c) => `auth:${getClientIp(c)}`,
|
||||
handler: (c) =>
|
||||
c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
meta: {
|
||||
requestId: c.get('requestId') ?? 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
429,
|
||||
),
|
||||
});
|
||||
|
||||
export function createAuthRoutes() {
|
||||
const routes = new Hono();
|
||||
|
||||
routes.post('/register', authAbuseLimiter, jsonValidator(registerSchema), async (c) => {
|
||||
const { email, password } = c.req.valid('json');
|
||||
const result = await register(email, password);
|
||||
return created(c, result);
|
||||
});
|
||||
|
||||
routes.post('/login', authAbuseLimiter, jsonValidator(loginSchema), async (c) => {
|
||||
const { email, password } = c.req.valid('json');
|
||||
const result = await login(email, password);
|
||||
return success(c, result);
|
||||
});
|
||||
|
||||
routes.post('/guest', authAbuseLimiter, async (c) => {
|
||||
const result = await createGuest();
|
||||
return created(c, result);
|
||||
});
|
||||
|
||||
routes.get('/me', requireAuth, async (c) => {
|
||||
const authUser = c.get('authUser');
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: authUser.id },
|
||||
include: { profile: true, identities: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return success(c, {
|
||||
id: authUser.externalId,
|
||||
email: authUser.email,
|
||||
isGuest: authUser.isGuest,
|
||||
profileComplete: false,
|
||||
});
|
||||
}
|
||||
|
||||
return success(c, toPublicUser(user));
|
||||
});
|
||||
|
||||
routes.post('/logout', requireAuth, async (c) => {
|
||||
const authUser = c.get('authUser');
|
||||
await logout(authUser.sessionId);
|
||||
return success(c, { loggedOut: true });
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const emailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Email is required')
|
||||
.email('Invalid email')
|
||||
.max(191)
|
||||
.transform((value) => value.toLowerCase());
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(128, 'Password must be at most 128 characters');
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: z.string().min(1, 'Password is required').max(128),
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AuthProvider, Prisma } from '@prisma/client';
|
||||
import { env } from '../../config/index.js';
|
||||
import { prisma } from '../../lib/prisma.js';
|
||||
import {
|
||||
ConflictError,
|
||||
InvalidCredentialsError,
|
||||
UnauthorizedError,
|
||||
} from '../../shared/errors/index.js';
|
||||
import { hashPassword, verifyPassword } from './password.js';
|
||||
import { generateSessionToken, hashSessionToken } from './sessionToken.js';
|
||||
import type { AuthSuccess, PublicUser } from './auth.types.js';
|
||||
|
||||
const GENERIC_CREDENTIALS_MESSAGE = 'Invalid email or password';
|
||||
|
||||
type UserWithRelations = Prisma.UserGetPayload<{
|
||||
include: {
|
||||
profile: true;
|
||||
identities: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
let dummyPasswordHash: string | undefined;
|
||||
|
||||
async function dummyHash(): Promise<string> {
|
||||
if (!dummyPasswordHash) {
|
||||
dummyPasswordHash = await hashPassword('timing-safe-dummy-value');
|
||||
}
|
||||
|
||||
return dummyPasswordHash;
|
||||
}
|
||||
|
||||
function sessionTtlMs(): number {
|
||||
return env.AUTH_SESSION_TTL_HOURS * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function emailFromIdentities(user: UserWithRelations): string | null {
|
||||
const passwordIdentity = user.identities.find((identity) => identity.provider === AuthProvider.password);
|
||||
return passwordIdentity?.email ?? null;
|
||||
}
|
||||
|
||||
export function isProfileComplete(profile: { name: string; age: number } | null | undefined): boolean {
|
||||
if (!profile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = profile.name.trim();
|
||||
return name.length > 0 && name.length <= 100 && Number.isInteger(profile.age);
|
||||
}
|
||||
|
||||
export function toPublicUser(user: UserWithRelations): PublicUser {
|
||||
return {
|
||||
id: user.externalId,
|
||||
email: emailFromIdentities(user),
|
||||
isGuest: user.isGuest,
|
||||
profileComplete: isProfileComplete(user.profile),
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(userId: bigint): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + sessionTtlMs());
|
||||
|
||||
await prisma.authSession.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async function loadUser(userId: bigint): Promise<UserWithRelations> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { profile: true, identities: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedError('Invalid or expired session');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string): Promise<AuthSuccess> {
|
||||
const existing = await prisma.authIdentity.findUnique({
|
||||
where: {
|
||||
provider_providerSubject: {
|
||||
provider: AuthProvider.password,
|
||||
providerSubject: email,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictError('An account with this email already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
try {
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
isGuest: false,
|
||||
identities: {
|
||||
create: {
|
||||
provider: AuthProvider.password,
|
||||
providerSubject: email,
|
||||
email,
|
||||
passwordHash,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { profile: true, identities: true },
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
const session = await createSession(user.id);
|
||||
|
||||
return {
|
||||
user: toPublicUser(user),
|
||||
sessionToken: session.token,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
throw new ConflictError('An account with this email already exists');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<AuthSuccess> {
|
||||
const identity =
|
||||
(await prisma.authIdentity.findUnique({
|
||||
where: {
|
||||
provider_providerSubject: {
|
||||
provider: AuthProvider.password,
|
||||
providerSubject: email,
|
||||
},
|
||||
},
|
||||
})) ??
|
||||
(await prisma.authIdentity.findFirst({
|
||||
where: {
|
||||
provider: AuthProvider.password,
|
||||
email,
|
||||
},
|
||||
}));
|
||||
|
||||
if (!identity?.passwordHash) {
|
||||
await verifyPassword(password, await dummyHash());
|
||||
throw new InvalidCredentialsError();
|
||||
}
|
||||
|
||||
const matches = await verifyPassword(password, identity.passwordHash);
|
||||
|
||||
if (!matches) {
|
||||
throw new InvalidCredentialsError();
|
||||
}
|
||||
|
||||
const user = await loadUser(identity.userId);
|
||||
const session = await createSession(user.id);
|
||||
|
||||
return {
|
||||
user: toPublicUser(user),
|
||||
sessionToken: session.token,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGuest(): Promise<AuthSuccess> {
|
||||
const subject = randomUUID();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
isGuest: true,
|
||||
identities: {
|
||||
create: {
|
||||
provider: AuthProvider.guest,
|
||||
providerSubject: subject,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { profile: true, identities: true },
|
||||
});
|
||||
|
||||
const session = await createSession(user.id);
|
||||
|
||||
return {
|
||||
user: toPublicUser(user),
|
||||
sessionToken: session.token,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSessionToken(rawToken: string) {
|
||||
const tokenHash = hashSessionToken(rawToken);
|
||||
const session = await prisma.authSession.findUnique({
|
||||
where: { tokenHash },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
profile: true,
|
||||
identities: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt.getTime() <= Date.now()) {
|
||||
throw new UnauthorizedError('Invalid or expired session');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
|
||||
export async function logout(sessionId: bigint): Promise<void> {
|
||||
await prisma.authSession.updateMany({
|
||||
where: {
|
||||
id: sessionId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { GENERIC_CREDENTIALS_MESSAGE };
|
||||
@@ -0,0 +1,25 @@
|
||||
export type PublicUser = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
isGuest: boolean;
|
||||
profileComplete: boolean;
|
||||
};
|
||||
|
||||
export type PublicProfile = {
|
||||
name: string;
|
||||
age: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AuthSuccess = {
|
||||
user: PublicUser;
|
||||
sessionToken: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type CurrentUserResponse = PublicUser;
|
||||
|
||||
export type CurrentUserWithProfile = PublicUser & {
|
||||
profile: PublicProfile | null;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createAuthRoutes } from './auth.routes.js';
|
||||
export { requireAuth } from '../../middleware/auth.js';
|
||||
@@ -0,0 +1,24 @@
|
||||
import { hash, verify, Algorithm } from '@node-rs/argon2';
|
||||
|
||||
const ARGON2_OPTIONS = {
|
||||
algorithm: Algorithm.Argon2id,
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||
try {
|
||||
return await verify(passwordHash, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isArgon2idHash(value: string): boolean {
|
||||
return value.startsWith('$argon2id$');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
const TOKEN_BYTES = 32;
|
||||
|
||||
export function generateSessionToken(): string {
|
||||
return randomBytes(TOKEN_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string): string {
|
||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
Reference in New Issue
Block a user