first commit 🎉

This commit is contained in:
2025-10-08 11:37:54 +07:00
commit 4ec61fa51e
61 changed files with 4558 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import { Elysia } from 'elysia'
import authRouter from '~/modules/auth/router'
import userRouter from '~/modules/user/router'
export const router = new Elysia({
name: 'codebase.router',
})
.use(authRouter)
.use(userRouter)
.get('/', () => ({
message: 'This service is running as expected.',
}))
+15
View File
@@ -0,0 +1,15 @@
import { Elysia } from 'elysia'
import { PORT } from '~/config'
import { corsPlugin, defaultHandlerPlugin, gracefulServerPlugin, otelPlugin, setServerIsReady, swaggerPlugin } from '~/plugins'
import { router } from './router'
new Elysia()
.use(otelPlugin)
.use(corsPlugin)
.use(gracefulServerPlugin)
.use(defaultHandlerPlugin)
.use(swaggerPlugin)
.use(router)
.listen(PORT, () => {
setServerIsReady()
})
+72
View File
@@ -0,0 +1,72 @@
export const ENV = process.env.NODE_ENV || 'development'
export const PORT = Number(process.env.PORT || 9000)
export const timeZone = process.env.TIMEZONE || 'Asia/Jakarta'
export const attendanceCheckInHour = Number(process.env.ATTENDANCE_CHECK_IN_HOUR || 8)
export const dbDsn = process.env.DB_DSN || ''
export const basicAuth = {
username: process.env.BASIC_AUTH_USERNAME || 'basicauth',
password: process.env.BASIC_AUTH_PASSWORD || 'supersecret',
}
export const jwt = {
algorithm: process.env.JWT_ALG || 'ES256', // ES256 | HS256 | RS256
key: {
secret: process.env.JWT_SECRET || '', // for HS256 algorithm
public: String(process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'), // for ES256 or RS256 algorithm
private: String(process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'), // for ES256 or RS256 algorithm
},
claims: {
issuer: process.env.JWT_ISS || 'skyrain',
audience: process.env.JWT_AUD || 'skyrain-mbg',
},
expires: {
access: process.env.JWT_EXP_ACCESS || '15m',
},
}
export const s3Configs = {
config: {
region: process.env.S3_REGION || 'us-east-1',
endpoint: process.env.S3_URL || '',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || '',
},
forcePathStyle: true,
},
bucket: process.env.S3_BUCKET_NAME || 'mbg',
}
export const otel = {
enabled: process.env.OTEL_ENABLED === 'true',
serviceName: process.env.OTEL_SERVICE_NAME || 'backend-node-mbg',
tracesExporter: process.env.OTEL_TRACES_EXPORTER || 'otlp',
otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4319/v1/traces',
}
export const CORSConfig = {
urls: process.env.CORS_URLS || '*',
methods: process.env.CORS_METHODS || '*',
}
export const redis = {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT || 6378),
username: process.env.REDIS_USER,
password: process.env.REDIS_PASSWORD,
tls: Boolean(process.env.REDIS_TLS || false),
db: Number(process.env.REDIS_DB),
}
// const minioHost = process.env.MINIO_HOST || ''
// export const minio = {
// endPoint: minioHost,
// port: Number(process.env.MINIO_PORT || 9000),
// useSSL: !!(process.env.MINIO_SSL || false),
// accessKey: String(process.env.MINIO_ACCESS_KEY) || '',
// secretKey: process.env.MINIO_SECRET_KEY || '',
// }
// export const uploadBucket = process.env.MINIO_BUCKET || ''
+1
View File
@@ -0,0 +1 @@
export { noDeleteTimestamps, timestamps } from './timestamps'
+11
View File
@@ -0,0 +1,11 @@
import { timestamp } from 'drizzle-orm/pg-core'
export const noDeleteTimestamps = {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().$onUpdate(() => new Date()),
}
export const timestamps = {
...noDeleteTimestamps,
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}
+13
View File
@@ -0,0 +1,13 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import { dbDsn } from '~/config'
import { schema } from './model'
export const client = new Pool({
connectionString: dbDsn,
})
export const db = drizzle({ client, schema: schema })
export { table, type Table } from './model'
export { spread, spreads } from './utils'
+15
View File
@@ -0,0 +1,15 @@
import users, * as userSchema from './schema/users'
// relation
// import * as relations from './relations'
export const schema = {
// ...relations,
...userSchema,
}
export const table = {
users,
} as const
export type Table = typeof table
+2
View File
@@ -0,0 +1,2 @@
// import { relations } from 'drizzle-orm'
// import users from './schema/users'
+26
View File
@@ -0,0 +1,26 @@
import { boolean, pgEnum, pgTable, serial, text, varchar } from 'drizzle-orm/pg-core'
import { timestamps } from '../customTypes'
export const userRoles = [
'admin',
'visitor',
'user',
'volunteer',
] as const
export const roleEnum = pgEnum('role', userRoles)
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).unique(),
phone: varchar({ length: 255 }),
password: varchar({ length: 127 }),
image: text(),
googleId: varchar('google_id', { length: 255 }),
role: roleEnum().notNull().default('user'),
status: boolean().notNull().default(true),
...timestamps,
})
export default users
+102
View File
@@ -0,0 +1,102 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-empty-object-type */
/**
* @lastModified 2025-02-04
* @see https://elysiajs.com/integrations/drizzle.html#utility
*/
import { Kind, type TObject } from '@sinclair/typebox'
import {
createInsertSchema,
createSelectSchema,
BuildSchema,
} from 'drizzle-typebox'
// import { table } from './schema'
import type { Table } from 'drizzle-orm'
type Spread<
T extends TObject | Table,
Mode extends 'select' | 'insert' | undefined,
> = T extends TObject<infer Fields>
? {
[K in keyof Fields]: Fields[K]
}
: T extends Table
? Mode extends 'select'
? BuildSchema<
'select',
T['_']['columns'],
undefined
>['properties']
: Mode extends 'insert'
? BuildSchema<
'insert',
T['_']['columns'],
undefined
>['properties']
: {}
: {}
/**
* Spread a Drizzle schema into a plain object
*/
export const spread = <
T extends TObject | Table,
Mode extends 'select' | 'insert' | undefined,
>(
schema: T,
mode?: Mode,
): Spread<T, Mode> => {
const newSchema: Record<string, unknown> = {}
let table
switch (mode) {
case 'insert':
case 'select':
if (Kind in schema) {
table = schema
break
}
table = mode === 'insert'
? createInsertSchema(schema)
: createSelectSchema(schema)
break
default:
if (!(Kind in schema)) throw new Error('Expect a schema')
table = schema
}
for (const key of Object.keys(table.properties))
newSchema[key] = table.properties[key]
return newSchema as any
}
/**
* Spread a Drizzle Table into a plain object
*
* If `mode` is 'insert', the schema will be refined for insert
* If `mode` is 'select', the schema will be refined for select
* If `mode` is undefined, the schema will be spread as is, models will need to be refined manually
*/
export const spreads = <
T extends Record<string, TObject | Table>,
Mode extends 'select' | 'insert' | undefined,
>(
models: T,
mode?: Mode,
): {
[K in keyof T]: Spread<T[K], Mode>
} => {
const newSchema: Record<string, unknown> = {}
const keys = Object.keys(models)
for (const key of keys) newSchema[key] = spread(models[key], mode)
return newSchema as any
}
+323
View File
@@ -0,0 +1,323 @@
import { createClient } from 'redis'
import { redis as redisConf } from '~/config'
type Client = ReturnType<typeof createClient>
interface RedisGlobal {
client?: Client
subscriber?: Client
connectingPromise?: Promise<Client>
subscriberConnectingPromise?: Promise<Client>
listenersAttached?: boolean
isHealthy?: boolean
}
const globalKey = '__redis'
const globalRedis = (globalThis as unknown as Record<string, RedisGlobal>)[globalKey]
if (!globalRedis) {
(globalThis as unknown as Record<string, RedisGlobal>)[globalKey] = {}
}
/**
* Gets the Redis configuration from environment variables.
* @returns The Redis client options.
*/
function getRedisConfig() {
const { host, port, username, password, db, tls } = redisConf
const socket: Record<string, unknown> = {
host,
port,
reconnectStrategy: (retries: number) => {
const delay = Math.min(Math.pow(2, retries) * 100, 3000)
return delay
},
keepAlive: true,
timeout: 30000,
}
if (tls) {
socket.tls = true
}
return {
host,
port,
username,
password,
database: db,
socket,
}
}
/**
* Lazily creates and returns a shared Redis client.
* @returns A promise that resolves to the Redis client.
*/
export async function getRedis(): Promise<Client> {
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
if (globalRedis.client && globalRedis.client.isOpen) {
return globalRedis.client
}
if (globalRedis.connectingPromise) {
return globalRedis.connectingPromise
}
const config = getRedisConfig()
const client = createClient(config)
// Attach listeners only once
if (!globalRedis.listenersAttached) {
client.on('connect', () => {
globalRedis.isHealthy = false
console.log('[Redis] Connected')
})
client.on('ready', () => {
globalRedis.isHealthy = true
console.log('[Redis] Ready')
})
client.on('reconnecting', () => {
globalRedis.isHealthy = false
console.log('[Redis] Reconnecting')
})
client.on('end', () => {
globalRedis.isHealthy = false
console.log('[Redis] Connection ended')
})
client.on('error', (err) => {
globalRedis.isHealthy = false
console.error('[Redis] Error:', err.message)
})
globalRedis.listenersAttached = true
}
globalRedis.connectingPromise = client.connect().then(() => {
globalRedis.client = client as Client
delete globalRedis.connectingPromise
return client as Client
}).catch((err) => {
delete globalRedis.connectingPromise
throw err
}) as Promise<Client>
return globalRedis.connectingPromise
}
/**
* Checks if the Redis connection is healthy.
* @returns True if healthy.
*/
export function isRedisHealthy(): boolean {
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
return globalRedis.isHealthy || false
}
/**
* Lazily creates and returns a shared Redis subscriber client.
* @returns A promise that resolves to the Redis subscriber client.
*/
export async function getRedisSubscriber(): Promise<Client> {
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
if (globalRedis.subscriber && globalRedis.subscriber.isOpen) {
return globalRedis.subscriber
}
if (globalRedis.subscriberConnectingPromise) {
return globalRedis.subscriberConnectingPromise
}
const client = await getRedis()
const subscriber = client.duplicate()
globalRedis.subscriberConnectingPromise = subscriber.connect().then(() => {
globalRedis.subscriber = subscriber
delete globalRedis.subscriberConnectingPromise
return subscriber
}).catch((err) => {
delete globalRedis.subscriberConnectingPromise
throw err
})
return globalRedis.subscriberConnectingPromise
}
/**
* Disconnects the Redis connection gracefully.
*/
export async function disconnectRedis(): Promise<void> {
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
if (globalRedis.client && globalRedis.client.isOpen) {
await globalRedis.client.disconnect()
delete globalRedis.client
}
if (globalRedis.subscriber && globalRedis.subscriber.isOpen) {
await globalRedis.subscriber.disconnect()
delete globalRedis.subscriber
}
}
// Register process listeners for graceful shutdown
if (process.env.NODE_ENV !== 'test') {
let shuttingDown = false
const shutdown = async () => {
if (shuttingDown) return
shuttingDown = true
await disconnectRedis()
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
process.on('beforeExit', shutdown)
}
/**
* Executes a function with a Redis client.
* @param fn The function to execute.
* @returns The result of the function.
*/
export async function withRedis<T>(fn: (c: Client) => Promise<T>): Promise<T> {
const client = await getRedis()
return fn(client)
}
/**
* Pings the Redis server.
* @returns The PONG response.
*/
export async function ping(): Promise<string> {
return withRedis(async (c) => c.ping())
}
/**
* Creates a Redis key with optional prefix.
* @param parts The key parts.
* @returns The full key.
*/
export function redisKey(...parts: (string | number)[]): string {
const prefix = process.env.REDIS_KEY_PREFIX
const keyParts = prefix ? [prefix, ...parts] : parts
return keyParts.map(String).join(':')
}
/**
* Gets a value from Redis.
* @param key The key.
* @returns The value or null.
*/
export async function get(key: string): Promise<string | null> {
return withRedis(async (c) => c.get(key))
}
/**
* Sets a value in Redis.
* @param key The key.
* @param value The value.
* @param ttlSeconds Optional TTL.
* @returns OK or null.
*/
export async function set(key: string, value: string, ttlSeconds?: number): Promise<string | null> {
return withRedis(async (c) => {
if (ttlSeconds) {
return c.setEx(key, ttlSeconds, value)
}
return c.set(key, value)
})
}
/**
* Deletes keys from Redis.
* @param keys The keys.
* @returns The number of deleted keys.
*/
export async function del(keys: string | string[]): Promise<number> {
const keyArray = Array.isArray(keys) ? keys : [keys]
return withRedis(async (c) => c.del(keyArray))
}
/**
* Increments a key by a value.
* @param key The key.
* @param by The increment value.
* @returns The new value.
*/
export async function incrBy(key: string, by = 1): Promise<number> {
return withRedis(async (c) => c.incrBy(key, by))
}
/**
* Checks if keys exist.
* @param keys The keys.
* @returns The number of existing keys.
*/
export async function exists(keys: string | string[]): Promise<number> {
const keyArray = Array.isArray(keys) ? keys : [keys]
return withRedis(async (c) => c.exists(keyArray))
}
/**
* Sets a JSON value in Redis.
* @param key The key.
* @param value The value.
* @param ttlSeconds Optional TTL.
* @returns OK or null.
*/
export async function setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<string | null> {
const json = JSON.stringify(value)
return set(key, json, ttlSeconds)
}
/**
* Gets a JSON value from Redis.
* @param key The key.
* @returns The parsed value or null.
*/
export async function getJson<T>(key: string): Promise<T | null> {
const value = await get(key)
if (!value) return null
try {
return JSON.parse(value) as T
} catch {
return null
}
}
/**
* Acquires a distributed lock.
* @param key The lock key.
* @param ttlMs The TTL in milliseconds.
* @param value Optional value.
* @returns The lock object or null.
*/
export async function acquireLock(
key: string,
ttlMs: number,
value = 'locked',
): Promise<null | { value: string, unlock: () => Promise<boolean> }> {
const result = await withRedis(async (c) => c.set(key, value, { NX: true, PX: ttlMs }))
if (result !== 'OK') return null
return {
value,
unlock: () => releaseLock(key, value),
}
}
/**
* Releases a distributed lock.
* @param key The lock key.
* @param value The expected value.
* @returns True if released.
*/
export async function releaseLock(key: string, value: string): Promise<boolean> {
const script = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
const result = await withRedis(async (c) => c.eval(script, { keys: [key], arguments: [value] }))
return result === 1
}
@@ -0,0 +1,5 @@
export class DataAlreadyExistsError extends Error {
constructor(public readonly message: string) {
super(message)
}
}
@@ -0,0 +1,7 @@
export type SlashPath = `/${string}`
export class FieldValidationError extends Error {
constructor(public readonly path: SlashPath, public readonly message: string) {
super(message)
}
}
@@ -0,0 +1,7 @@
export type SlashPath = `/${string}`
export class MultiFieldValidationError extends Error {
constructor(public readonly errors: [ path: SlashPath, message: string ][], public readonly message = 'Found errors on multiple fields.') {
super(message)
}
}
@@ -0,0 +1,5 @@
export class UnauthenticatedError extends Error {
constructor(public readonly message: string) {
super(message)
}
}
+16
View File
@@ -0,0 +1,16 @@
import { FieldValidationError } from './FieldValidationError'
import { MultiFieldValidationError } from './MultiFieldValidationError'
import { UnauthenticatedError } from './UnauthenticatedError'
import { DataAlreadyExistsError } from './DataAlreadyExistsError'
export * from './FieldValidationError'
export * from './UnauthenticatedError'
export * from './DataAlreadyExistsError'
export { InternalServerError } from 'elysia'
export default {
FIELD_VALIDATION: FieldValidationError,
MULTI_FIELD_VALIDATION: MultiFieldValidationError,
UNAUTHENTICATED: UnauthenticatedError,
DATA_ALREADY_EXISTS: DataAlreadyExistsError,
}
+115
View File
@@ -0,0 +1,115 @@
import {
importPKCS8,
importSPKI,
JWTHeaderParameters,
JWTPayload,
jwtVerify,
SignJWT,
errors,
} from 'jose'
import { jwt as jwtConfig } from '~/config'
import { type UserInfo } from '~/modules/auth/schema'
export const JWTInvalid = errors.JWTInvalid
export const JWTExpired = errors.JWTExpired
type Alg = 'HS256' | 'RS256' | 'ES256'
interface SignOptions {
issuer?: string
audience?: string | string[]
expiresIn?: string | number
kid?: string
alg?: Alg // optional override
}
interface VerifyOptions {
issuer?: string | string[]
audience?: string | string[]
clockTolerance?: string | number
alg?: Alg // optional override
}
export type JwtClaims = JWTPayload & {
user: UserInfo
}
let cachedAlg: Alg | null = null
let cachedSignerKey: CryptoKey | null = null
let cachedVerifierKey: CryptoKey | null = null
function getAlg(override?: Alg): Alg {
const envAlg = jwtConfig.algorithm.toUpperCase()
const alg = (override || envAlg) as Alg
if (!['HS256', 'RS256', 'ES256'].includes(alg)) {
throw new Error(`Unsupported JWT_ALG: ${alg}`)
}
return alg
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
const raw = new TextEncoder().encode(secret)
return crypto.subtle.importKey(
'raw',
raw,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
)
}
async function loadKeys(alg: Alg): Promise<{ signKey: CryptoKey, verifyKey: CryptoKey }> {
if (cachedAlg === alg && cachedSignerKey && cachedVerifierKey) {
return { signKey: cachedSignerKey, verifyKey: cachedVerifierKey }
}
if (alg === 'HS256') {
const key = await importHmacKey(jwtConfig.key.secret)
cachedAlg = alg
cachedSignerKey = key
cachedVerifierKey = key
return { signKey: key, verifyKey: key }
}
const priv = await importPKCS8(jwtConfig.key.private, alg)
const pub = await importSPKI(jwtConfig.key.public, alg)
cachedAlg = alg
cachedSignerKey = priv
cachedVerifierKey = pub
return { signKey: priv, verifyKey: pub }
}
export async function sign(
claims: JwtClaims,
opts: SignOptions = {},
): Promise<string> {
const alg = getAlg(opts.alg)
const { signKey } = await loadKeys(alg)
const header: JWTHeaderParameters = { alg, ...(opts.kid ? { kid: opts.kid } : {}) }
const builder = new SignJWT(claims).setProtectedHeader(header).setIssuedAt()
if (opts.issuer) builder.setIssuer(opts.issuer)
if (opts.audience) builder.setAudience(opts.audience)
builder.setExpirationTime(opts.expiresIn ?? jwtConfig.expires.access)
return builder.sign(signKey)
}
export async function verify<T extends JWTPayload = JwtClaims>(
token: string,
opts: VerifyOptions = {},
): Promise<T> {
const alg = getAlg(opts.alg)
const { verifyKey } = await loadKeys(alg)
const { payload } = await jwtVerify(token, verifyKey, {
algorithms: [alg],
issuer: opts.issuer,
audience: opts.audience,
clockTolerance: opts.clockTolerance ?? '5s',
})
return payload as T
}
+145
View File
@@ -0,0 +1,145 @@
import { createHmac, timingSafeEqual } from 'crypto'
/**
* Canonicalizes an object by recursively removing 'signature' properties,
* sorting object keys lexicographically, preserving array order,
* omitting undefined, functions, and symbols from objects,
* converting undefined or unsupported array elements to null,
* converting Dates to ISO strings, BigInt to strings,
* and non-finite numbers (NaN/Infinity/-Infinity) to null.
* Throws on circular references or unsupported objects.
* @param data The data to canonicalize.
* @returns The canonicalized, signature-free structure.
*/
export function canonicalizeObject<T = unknown>(data: T): unknown {
const visited = new Set<unknown>()
function recurse(value: unknown): unknown {
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
return value
}
if (typeof value === 'number') {
if (!isFinite(value)) {
return null
}
return value
}
if (typeof value === 'bigint') {
return value.toString()
}
if (value instanceof Date) {
return value.toISOString()
}
if (Array.isArray(value)) {
if (visited.has(value)) {
throw new Error('Circular reference detected in array')
}
visited.add(value)
const result = value.map((item) => {
if (item === undefined) {
return null
}
return recurse(item)
})
visited.delete(value)
return result
}
if (typeof value === 'object') {
if (visited.has(value)) {
throw new Error('Circular reference detected in object')
}
visited.add(value)
const obj = value as Record<string | symbol, unknown>
const result: Record<string, unknown> = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key) && typeof key === 'string' && key !== 'signature') {
const val = obj[key]
if (val !== undefined && typeof val !== 'function' && typeof val !== 'symbol') {
result[key] = recurse(val)
}
}
}
// Sort keys lexicographically
const sortedKeys = Object.keys(result).sort()
const sortedResult: Record<string, unknown> = {}
for (const key of sortedKeys) {
sortedResult[key] = result[key]
}
visited.delete(value)
return sortedResult
}
// Unsupported types
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'undefined') {
throw new Error(`Unsupported type: ${typeof value}`)
}
// If it has toJSON, try that
if (value && typeof (value as { toJSON(): unknown }).toJSON === 'function') {
return recurse((value as { toJSON(): unknown }).toJSON())
}
throw new Error(`Cannot canonicalize object of type ${typeof value}`)
}
return recurse(data)
}
/**
* Returns the canonical JSON string of the data.
* @param data The data to canonicalize.
* @returns The canonical JSON string.
*/
export function canonicalize(data: unknown): string {
return JSON.stringify(canonicalizeObject(data))
}
/**
* Signs the data using HMAC-SHA256 and returns the signature as lowercase hex.
* @param data The data to sign.
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY.
* @returns The signature.
*/
export function sign(data: unknown, secretKey?: string): string {
if (!secretKey) {
secretKey = process.env.SECRET_KEY
if (!secretKey || secretKey.trim() === '') {
throw new Error('Secret key is required. Provide it as a parameter or set process.env.SECRET_KEY.')
}
}
const canonical = canonicalize(data)
const hmac = createHmac('sha256', secretKey)
hmac.update(canonical, 'utf8')
return hmac.digest('hex')
}
/**
* Verifies the signature of the data using HMAC-SHA256 with constant-time comparison.
* @param data The data to verify.
* @param signature The expected signature.
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY.
* @returns True if the signature is valid, false otherwise.
*/
export function verify(data: unknown, signature: string, secretKey?: string): boolean {
try {
const computed = sign(data, secretKey)
const computedBuf = Buffer.from(computed, 'hex')
const providedBuf = Buffer.from(signature, 'hex')
if (computedBuf.length !== providedBuf.length) {
return false
}
return timingSafeEqual(computedBuf, providedBuf)
} catch {
return false
}
}
+6
View File
@@ -0,0 +1,6 @@
// formats.ts
import { FormatRegistry } from '@sinclair/typebox'
FormatRegistry.Set('mobilePhone', (value): boolean =>
typeof value === 'string' && /^08\d{8,13}$/.test(value),
)
+3
View File
@@ -0,0 +1,3 @@
export const passwordHash = (password: string) => Bun.password.hashSync(password, { algorithm: 'bcrypt' })
export const passwordVerify = (password: string, hash: string) => Bun.password.verifySync(password, hash)
+22
View File
@@ -0,0 +1,22 @@
import { HTTPHeaders, StatusMap } from 'elysia'
import { basicAuth as credential } from '~/config'
import { UnauthenticatedError } from '~/helpers/errors'
import { logger } from '~/plugins'
export const basicAuthMiddleware = (
headers: Record<string, string | undefined>,
set: {
headers: HTTPHeaders
status?: number | keyof StatusMap
},
) => {
const expectedAuth = Buffer.from(`${credential.username}:${credential.password}`).toString('base64')
if (headers.authorization !== `Basic ${expectedAuth}`) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed basic authentication attempt')
set.status = 401
set.headers['WWW-Authenticate'] = 'Basic realm="Restricted Area"'
throw new UnauthenticatedError('Unauthenticated.')
}
}
+65
View File
@@ -0,0 +1,65 @@
import { HTTPHeaders, StatusMap } from 'elysia'
import { UnauthenticatedError } from '~/helpers/errors'
import { verify, JWTExpired, JWTInvalid } from '~/helpers/jwt'
import { logger } from '~/plugins'
const throwError = (
set: {
headers: HTTPHeaders
status?: number | keyof StatusMap
},
message = 'Unauthenticated.',
) => {
set.status = 401
set.headers['WWW-Authenticate'] = 'Bearer realm="Restricted Area"'
throw new UnauthenticatedError(message)
}
export const bearerAuthMiddleware = async (
headers: Record<string, string | undefined>,
set: {
headers: HTTPHeaders
status?: number | keyof StatusMap
},
) => {
if (headers.authorization === undefined) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed bearer authentication attempt due to missing auth header')
throwError(set)
}
if (!headers.authorization?.startsWith('Bearer ')) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed bearer authentication attempt due to invalid auth header')
throwError(set)
}
try {
const payload = await verify(`${headers.authorization}`.replace(/^Bearer /, ''))
return payload.user
} catch (error) {
let message = undefined
if (error instanceof JWTExpired) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed bearer authentication attempt due to expired token')
message = 'Token expired.'
}
if (error instanceof JWTInvalid) {
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed bearer authentication attempt due to invalid token')
message = 'Token invalid.'
}
logger.debug({
'headers.authorization': headers.authorization,
}, 'Failed bearer authentication attempt due to failed verification')
throwError(set, message)
}
}
+2
View File
@@ -0,0 +1,2 @@
export { basicAuthMiddleware } from './basicAuth'
export { bearerAuthMiddleware } from './bearerAuth'
+40
View File
@@ -0,0 +1,40 @@
import { UnauthenticatedError } from '~/helpers/errors'
import { LoginBody, LoginResponse } from '../schema'
import UserQuery from '@/user/queries/query'
import { passwordVerify } from '~/helpers/utils'
import { logger } from '~/plugins'
import { isEmpty, omit } from 'lodash-es'
import { sign } from '~/helpers/jwt'
import { UserInfo } from '../schema'
export default abstract class CommandService {
static async login(body: LoginBody): Promise<LoginResponse> {
const user = await UserQuery.getUserByEmail(body.email)
if (!user) {
logger.debug({
'body.email': body.email,
}, 'Login failed due to user not found.')
throw new UnauthenticatedError('Invalid username or password.')
}
if (!isEmpty(user.password) && !passwordVerify(body.password, String(user.password))) {
logger.info({
'user.hasPassword': !isEmpty(user.password),
}, 'Login failed due to invalid password.')
throw new UnauthenticatedError('Invalid username or password.')
}
const docs = omit(user, ['password', 'deletedAt']) as UserInfo
const now = Date.now()
const sub = `${user.id}.${now.toString(36).toUpperCase()}`
const accessToken = await sign({
sub,
user: docs,
})
return {
accessToken,
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Elysia } from 'elysia'
import { basicAuthMiddleware, bearerAuthMiddleware } from '~/middlewares'
import { getInfoSchema, loginBody, loginResponseSchema } from './schema'
import CommandService from './commands/service'
export const router = new Elysia({
name: 'modules.auth',
detail: { tags: ['Authentication'] },
prefix: '/auth/v1',
})
.post('/login', async ({ headers, set, body }) => {
// basic auth middleware
basicAuthMiddleware(headers, set)
const user = await CommandService.login(body)
return {
message: 'Logged in.',
data: user,
}
}, {
body: loginBody,
response: loginResponseSchema,
detail: {
security: [{ basicAuth: [] }],
},
})
.get('/info', async ({ headers, set }) => {
// bearer auth middleware
const user = await bearerAuthMiddleware(headers, set)
return {
message: 'Get user info.',
data: user,
}
}, {
response: getInfoSchema,
})
export default router
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod'
import { selectUserSchema } from '../user/schema'
export const loginBody = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export type LoginBody = z.infer<typeof loginBody>
const loginResponse = z.object({
accessToken: z.string().describe('Access token of the user, encoded in JWT format'),
// refreshToken: z.string().describe('Refresh token of the user, encoded in JWT format'),
})
export type LoginResponse = z.infer<typeof loginResponse>
const userInfo = selectUserSchema.omit({
password: true,
deletedAt: true,
})
export const getInfoResponse = userInfo
export type UserInfo = z.infer<typeof getInfoResponse>
export const loginResponseSchema = {
200: z.object({
message: z.literal('Logged in.'),
data: loginResponse,
}),
401: z.object({
message: z.string(),
}),
}
export const getInfoSchema = {
200: z.object({
message: z.literal('Get user info.'),
data: getInfoResponse,
}),
401: z.object({
message: z.string(),
}),
}
+27
View File
@@ -0,0 +1,27 @@
import { db, table } from '~/db'
import type { NewUser } from '../schema'
import { eq } from 'drizzle-orm'
export default abstract class Command {
static async insertUser(user: NewUser) {
const newUser = await db.insert(table.users).values(user).returning()
return newUser[0]
}
static async updateUser(id: number, body: NewUser) {
const user = await db.update(table.users)
.set(body)
.where(eq(table.users.id, id))
.returning()
return user[0]
}
static async deleteUser(id: number) {
const user = await db.update(table.users)
.set({ deletedAt: new Date() })
.where(eq(table.users.id, id))
.returning()
return user[0]
}
}
+76
View File
@@ -0,0 +1,76 @@
import type { CreateUserSchema, NewUser } from '../schema'
import Command from './command'
import Query from '../queries/query'
import { NotFoundError } from 'elysia'
import { passwordHash } from '~/helpers/utils'
import { FieldValidationError } from '~/helpers/errors'
import { roleEnum } from '~/db/schema/users'
export default abstract class CommandService {
static async createUser(body: CreateUserSchema) {
if (!body.email && !body.phone) {
throw new FieldValidationError('/', 'Either email or phone number must be provided')
}
// check email availability
if (body.email) {
const checkEmail = await Query.getUserByEmail(body.email)
if (checkEmail) {
throw new FieldValidationError('/email', 'Email already in use')
}
}
if (body.password) {
body.password = passwordHash(body.password)
}
const newUser = {
...body,
role: body.role as typeof roleEnum.enumValues[number],
status: true,
}
const createdUser = await Command.insertUser(newUser)
return createdUser
}
static async updateUser(id: number, body: CreateUserSchema) {
// check id
const checkId = await Query.getUserById(id)
if (!checkId) {
throw new NotFoundError('User not found')
}
// check name
const checkName = await Query.getUserByName(body.name)
if (checkName && checkName.id !== id) {
throw new FieldValidationError('/name', 'Name already in use')
}
// check email
if (body.email) {
const checkEmail = await Query.getUserByEmail(body.email)
if (checkEmail?.id !== id) {
throw new FieldValidationError('/email', 'Email already in use')
}
}
const newUser = {
...body,
status: true,
} as NewUser
return Command.updateUser(id, newUser)
}
static async deleteUser(id: number) {
// check id
const checkId = await Query.getUserById(id)
if (!checkId) {
throw new NotFoundError('User not found')
}
return Command.deleteUser(id)
}
}
+52
View File
@@ -0,0 +1,52 @@
import { db } from '~/db'
export default abstract class Query {
static async listUser() {
const user = await db.query.users.findMany({
where: (u, { isNull }) => isNull(u.deletedAt),
})
return user
}
static async getUserById(id: number) {
const user = await db.query.users.findFirst({
columns: {
deletedAt: false,
},
where: (u, { and, eq, isNull }) => and(
eq(u.id, id),
isNull(u.deletedAt),
),
})
return user
}
static async getUserByEmail(email: string) {
const user = await db.query.users.findFirst({
columns: {
deletedAt: false,
},
where: (u, { and, eq, isNull }) => and(
eq(u.email, email),
isNull(u.deletedAt),
),
})
return user
}
static async getUserByName(name: string) {
const user = await db.query.users.findFirst({
columns: {
deletedAt: false,
},
where: (u, { and, eq, isNull }) => and(
eq(u.name, name),
isNull(u.deletedAt),
),
})
return user
}
}
+8
View File
@@ -0,0 +1,8 @@
import Query from './query'
export default abstract class QueryService {
static async getUsers() {
const allUsers = await Query.listUser()
return allUsers
}
}
+87
View File
@@ -0,0 +1,87 @@
import { Elysia, t } from 'elysia'
import { bearerAuthMiddleware } from '~/middlewares'
import { validationErrorSchema } from '~/plugins/defaultHandler'
import CommandService from './commands/service'
import QueryService from './queries/service'
import { createUserBody, createUserResponse, listUserResponse } from './schema'
export const router = new Elysia({
name: 'modules.user',
detail: { tags: ['Users'] },
prefix: '/user/v1/users',
})
.get('/', async ({ headers, set }) => {
await bearerAuthMiddleware(headers, set)
const users = await QueryService.getUsers()
return {
message: 'Get users.' as const,
data: users,
}
}, {
response: {
200: t.Object({
message: t.Literal('Get users.'),
data: listUserResponse,
}),
401: t.Object({
message: t.Literal('Unauthenticated.'),
}),
},
})
.post('/', async ({ body, headers, set }) => {
await bearerAuthMiddleware(headers, set)
const user = await CommandService.createUser(body)
return {
message: 'User created.',
data: user,
}
}, {
body: createUserBody,
response: {
200: t.Object({
message: t.Literal('User created.'),
data: createUserResponse,
}),
401: t.Object({
message: t.Literal('Unauthenticated.'),
}),
422: validationErrorSchema,
},
})
.put('/:id', async ({ params: { id }, body, headers, set }) => {
await bearerAuthMiddleware(headers, set)
const user = await CommandService.updateUser(id, body)
return {
message: 'User updated.' as const,
data: user,
}
}, {
body: createUserBody,
response: {
200: t.Object({
message: t.Literal('User updated.'),
data: createUserResponse,
}),
},
})
.delete('/:id', async ({ params: { id }, headers, set }) => {
await bearerAuthMiddleware(headers, set)
await CommandService.deleteUser(id)
return {
message: 'User deleted.' as const,
data: { id },
}
}, {
response: {
200: t.Object({
message: t.Literal('User deleted.'),
data: t.Object({ id: t.String() }),
}),
},
})
export default router
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod'
import { createSelectSchema, createInsertSchema } from 'drizzle-zod'
import { table as $t } from '~/db'
import { roleEnum } from '~/db/schema/users'
export const selectUserSchema = createSelectSchema($t.users)
export const $s = selectUserSchema.shape
export const $i = createInsertSchema($t.users).shape
export const roleSchema = createSelectSchema(roleEnum)
export const createUserBody = z.object({
name: $i.name.min(3),
email: $i.email,
phone: z.string().optional(),
password: z.string().min(8),
role: roleSchema.optional(), // will validate in service
partnerId: z.string().optional(),
})
export type CreateUserSchema = z.infer<typeof createUserBody>
export const createUserResponse = selectUserSchema.pick({
id: true,
name: true,
email: true,
phone: true,
role: true,
status: true,
createdAt: true,
})
export const listUserResponse = z.array(createUserResponse)
export type User = typeof $t.users.$inferSelect
export type NewUser = typeof $t.users.$inferInsert
+8
View File
@@ -0,0 +1,8 @@
import { cors } from '@elysiajs/cors'
import { CORSConfig } from '~/config'
export const corsPlugin = cors({
origin: CORSConfig.urls.split(','),
credentials: true,
methods: CORSConfig.methods.split(','),
})
+139
View File
@@ -0,0 +1,139 @@
import { Elysia, t, ValidationError } from 'elysia'
import { TypeCheck, type ValueError } from '@sinclair/typebox/compiler'
import logger from './logger'
import customErrors, { FieldValidationError } from '~/helpers/errors'
import { Prettify } from 'elysia/dist/types'
import { MultiFieldValidationError } from '~/helpers/errors/MultiFieldValidationError'
function isNumericStatus(c: unknown): c is number {
return typeof c === 'number'
}
export const validationSchema = t.Object({
path: t.String({ description: 'Usually contains the name of field being validated.' }),
message: t.String({ description: 'Contains the validation error message.' }),
})
export const validationErrorSchema = t.Object({
message: t.Literal('Failed to validate request.'),
details: t.Array(validationSchema, {
description: 'Contain details of validation errors being thrown.',
}),
})
type ValidationResponseItem = typeof validationSchema.static
export type ValidationResponse = ValidationResponse[]
export const defaultHandlerPlugin = new Elysia({
name: 'codebase.defaultHandler',
})
.error(customErrors)
.onError(({ error, code, status }) => {
switch (code) {
case 'NOT_FOUND':
return status(404, {
message: 'Route not found.',
})
case 'UNAUTHENTICATED':
return status(401, {
message: error.message,
})
case 'DATA_ALREADY_EXISTS':
return status(409, {
message: error.message,
})
case 'FIELD_VALIDATION':
return status(422, handleFieldValidationError(error))
case 'MULTI_FIELD_VALIDATION':
return status(422, handleMultiFieldValidationError(error))
case 'VALIDATION':
return status(422, handleValidationError(error))
case 'PARSE':
return status(400, {
message: 'Failed to parse request body.',
})
case 'UNKNOWN':
logger.error(error)
return {
message: 'Server error occured.',
details: {
error: error.name,
message: error.message,
},
}
default:
if (isNumericStatus(code)) {
const body = typeof error === 'string'
? { error: 'HTTP_ERROR', message: error }
: (error as Record<string, unknown>) ?? { error: 'HTTP_ERROR' }
return status(code, body)
}
logger.error(error)
return {
message: 'Server error occured.',
}
}
})
.as('global') // important to make sure this applies to all routes
const handleValidationError = (error: ValidationError) => {
const details: ValidationResponseItem[] = []
if (error.validator instanceof TypeCheck) {
const errIterator = error.validator.Errors(error.value)
for (const err of errIterator) {
details.push({
path: err.path || '/',
message: err.schema.error?.toString() || err.message,
// details: err,
})
}
} else {
for (const err of error.all as Prettify<{ summary: string } & ValueError>[]) {
if (typeof err.summary === 'string') {
details.push({
path: err.path || '/',
message: err.summary,
// details: err,
})
}
}
}
return {
message: 'Failed to validate request.',
details,
}
}
const handleFieldValidationError = (error: FieldValidationError) => {
const errors: ValidationResponseItem[] = [{
path: error.path || '/',
message: error.message,
// details: err,
}]
return {
message: 'Failed to validate request.',
errors,
}
}
const handleMultiFieldValidationError = (error: MultiFieldValidationError) => {
const errors: ValidationResponseItem[] = error.errors.map((err) => ({
path: err[0],
message: err[1],
}))
return {
message: error.message,
errors,
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Elysia } from 'elysia'
import { db } from '~/db'
export const drizzlePlugin = new Elysia({
name: 'plugins.dsizzle',
})
.decorate('db', db)
.as('global')
+14
View File
@@ -0,0 +1,14 @@
import { pluginGracefulServer } from 'graceful-server-elysia'
import logger from './logger'
import { PORT } from '~/config'
export const gracefulServerPlugin = pluginGracefulServer({
onStart: () => {
logger.info('Service is starting...')
},
onReady: () => {
logger.info(`Service is running at http://0.0.0.0:${PORT}`)
},
})
export { setServerIsReady } from 'graceful-server-elysia'
+7
View File
@@ -0,0 +1,7 @@
export { corsPlugin } from './cors'
export { defaultHandlerPlugin } from './defaultHandler'
export { gracefulServerPlugin, setServerIsReady } from './gracefulServer'
export { logger, loggerPlugin } from './logger'
export { drizzlePlugin } from './drizzle'
export { swaggerPlugin } from './swagger'
export { otelPlugin } from './otel'
+34
View File
@@ -0,0 +1,34 @@
import { LoggerOptions, TransportTargetOptions } from 'pino'
import { createPinoLogger } from '@bogeychan/elysia-logger'
import { ENV } from '~/config'
const loggerOptions: LoggerOptions = {
level: ENV === 'production' ? 'info' : 'debug',
formatters: {
bindings: () => ({}),
},
}
const loggerTransports: TransportTargetOptions[] = []
if (ENV === 'development') {
loggerTransports.push({
target: 'pino-pretty',
})
} else {
loggerTransports.push({
target: 'pino/file',
options: { destination: 1 },
})
}
export const logger = createPinoLogger({
...loggerOptions,
transport: {
targets: loggerTransports,
},
})
export const loggerPlugin = logger.into
export default logger
+27
View File
@@ -0,0 +1,27 @@
import { Elysia } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import { otel } from '~/config'
let sdk: NodeSDK | undefined
if (otel.enabled) {
sdk = new NodeSDK({
serviceName: otel.serviceName,
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: otel.otlpEndpoint,
}),
),
],
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
}
export const otelPlugin = otel.enabled ? opentelemetry() : (app: Elysia) => app
+6
View File
@@ -0,0 +1,6 @@
import { S3Client } from '@aws-sdk/client-s3'
import { s3Configs } from '~/config'
export const s3Plugin = new S3Client(s3Configs.config)
export const s3BucketName = s3Configs.bucket
+53
View File
@@ -0,0 +1,53 @@
import { openapi } from '@elysiajs/openapi'
import { OpenAPIV3 } from 'openapi-types'
import { PORT } from '~/config'
interface OpenApiDocWithTagGroups { 'x-tagGroups': { name: string, tags: string[] } }
type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGroups>>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>
export const swaggerPlugin = openapi({
path: '/swagger',
provider: 'scalar',
documentation: {
'info': {
title: 'Backend node Skyrain API Documentation',
version: 'v1.0.0',
},
'tags': [
{ name: 'Authentication', description: 'Authentication API' },
{ name: 'Users', description: 'User API' },
],
'servers': [
{
url: 'https://skyrainstudio.com',
description: 'production domain',
},
{
url: `http://localhost:${PORT}`,
description: 'localhost',
},
{
url: `https://skyrainstudio.com`,
description: 'Dev server',
},
],
// --- Grouping di atas tag ---
'x-tagGroups': [
{ name: 'Auth & IAM', tags: ['Authentication', 'Users'] },
],
'components': {
securitySchemes: {
basicAuth: {
type: 'http',
scheme: 'basic',
},
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
'security': [{ bearerAuth: [] }],
} as OpenApiDocumentation,
})