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,25 @@
|
||||
export const APP_NAME = 'Novya API';
|
||||
export const APP_DESCRIPTION = 'Novya learning platform backend API';
|
||||
|
||||
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 to disable cooldown (dev/testing). */
|
||||
export const SESSION_UNLOCK_INTERVAL_MS = 0;
|
||||
|
||||
export const DEFAULT_PAGE = 1;
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
export const MAX_PAGE_SIZE = 100;
|
||||
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
export const SENSITIVE_FIELD_PATTERNS = [
|
||||
/password/i,
|
||||
/otp/i,
|
||||
/token/i,
|
||||
/jwt/i,
|
||||
/api[_-]?key/i,
|
||||
/authorization/i,
|
||||
/secret/i,
|
||||
/prompt/i,
|
||||
/refresh[_-]?token/i,
|
||||
/answer/i,
|
||||
/content/i,
|
||||
] as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
export const ERROR_CODES = {
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
CONFLICT: 'CONFLICT',
|
||||
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
||||
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||
DATABASE_UNAVAILABLE: 'DATABASE_UNAVAILABLE',
|
||||
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
@@ -0,0 +1,16 @@
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
} as const;
|
||||
|
||||
export type HttpStatusCode = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS];
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
APP_NAME,
|
||||
APP_DESCRIPTION,
|
||||
DEFAULT_PAGE,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
MAX_PAGE_SIZE,
|
||||
REQUEST_ID_HEADER,
|
||||
SENSITIVE_FIELD_PATTERNS,
|
||||
} from './app.js';
|
||||
export { HTTP_STATUS, type HttpStatusCode } from './httpStatus.js';
|
||||
export { ERROR_CODES, type ErrorCode } from './errorCodes.js';
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ERROR_CODES, HTTP_STATUS, type ErrorCode, type HttpStatusCode } from '../constants/index.js';
|
||||
|
||||
export class AppError extends Error {
|
||||
readonly statusCode: HttpStatusCode;
|
||||
readonly code: ErrorCode;
|
||||
readonly details?: unknown;
|
||||
readonly isOperational: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
statusCode?: HttpStatusCode;
|
||||
code?: ErrorCode;
|
||||
details?: unknown;
|
||||
isOperational?: boolean;
|
||||
cause?: unknown;
|
||||
} = {},
|
||||
) {
|
||||
super(message, { cause: options.cause });
|
||||
this.name = 'AppError';
|
||||
this.statusCode = options.statusCode ?? HTTP_STATUS.INTERNAL_SERVER_ERROR;
|
||||
this.code = options.code ?? ERROR_CODES.INTERNAL_ERROR;
|
||||
this.details = options.details;
|
||||
this.isOperational = options.isOperational ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = 'Resource not found', details?: unknown) {
|
||||
super(message, {
|
||||
statusCode: HTTP_STATUS.NOT_FOUND,
|
||||
code: ERROR_CODES.NOT_FOUND,
|
||||
details,
|
||||
});
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message = 'Validation failed', details?: unknown) {
|
||||
super(message, {
|
||||
statusCode: HTTP_STATUS.UNPROCESSABLE_ENTITY,
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
details,
|
||||
});
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends AppError {
|
||||
constructor(message = 'Service unavailable', details?: unknown) {
|
||||
super(message, {
|
||||
statusCode: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
code: ERROR_CODES.SERVICE_UNAVAILABLE,
|
||||
details,
|
||||
});
|
||||
this.name = 'ServiceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Authentication required', details?: unknown) {
|
||||
super(message, {
|
||||
statusCode: HTTP_STATUS.UNAUTHORIZED,
|
||||
code: ERROR_CODES.UNAUTHORIZED,
|
||||
details,
|
||||
});
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message = 'Resource already exists', details?: unknown) {
|
||||
super(message, {
|
||||
statusCode: HTTP_STATUS.CONFLICT,
|
||||
code: ERROR_CODES.CONFLICT,
|
||||
details,
|
||||
});
|
||||
this.name = 'ConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden', details?: unknown) {
|
||||
super(message, {
|
||||
statusCode: HTTP_STATUS.FORBIDDEN,
|
||||
code: ERROR_CODES.FORBIDDEN,
|
||||
details,
|
||||
});
|
||||
this.name = 'ForbiddenError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCredentialsError extends AppError {
|
||||
constructor() {
|
||||
super('Invalid email or password', {
|
||||
statusCode: HTTP_STATUS.UNAUTHORIZED,
|
||||
code: ERROR_CODES.INVALID_CREDENTIALS,
|
||||
});
|
||||
this.name = 'InvalidCredentialsError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
AppError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ServiceUnavailableError,
|
||||
UnauthorizedError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
InvalidCredentialsError,
|
||||
} from './AppError.js';
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface ResponseMeta {
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ApiSuccess<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta: ResponseMeta;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
error: ApiErrorBody;
|
||||
meta: ResponseMeta;
|
||||
}
|
||||
|
||||
export type BaseResponse<T> = ApiSuccess<T> | ApiError;
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: bigint;
|
||||
externalId: string;
|
||||
email: string | null;
|
||||
isGuest: boolean;
|
||||
sessionId: bigint;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AuthenticatedUser } from './api.js';
|
||||
|
||||
export interface RequestContext {
|
||||
requestId: string;
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
requestId: string;
|
||||
requestContext: RequestContext;
|
||||
authUser: AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
ResponseMeta,
|
||||
ApiSuccess,
|
||||
ApiErrorBody,
|
||||
ApiError,
|
||||
BaseResponse,
|
||||
Pagination,
|
||||
PaginatedData,
|
||||
AuthenticatedUser,
|
||||
} from './api.js';
|
||||
|
||||
export type { RequestContext } from './context.js';
|
||||
|
||||
import './context.js';
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export function jsonValidator<T extends ZodType>(schema: T) {
|
||||
return zValidator('json', schema, (result) => {
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function queryValidator<T extends ZodType>(schema: T) {
|
||||
return zValidator('query', schema, (result) => {
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user