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:
ar.badiei
2026-09-03 21:01:52 +03:30
co-authored by Cursor
parent aa9236fb92
commit 370ac15471
193 changed files with 25997 additions and 1991 deletions
+86
View File
@@ -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;
}
+27
View File
@@ -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>;
+240
View File
@@ -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 };
+25
View File
@@ -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;
};
+2
View File
@@ -0,0 +1,2 @@
export { createAuthRoutes } from './auth.routes.js';
export { requireAuth } from '../../middleware/auth.js';
+24
View File
@@ -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$');
}
+11
View File
@@ -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');
}