mockup API device

This commit is contained in:
2025-10-08 22:13:24 +07:00
parent 4ec61fa51e
commit af4f6a3eea
11 changed files with 207 additions and 152 deletions
+6 -4
View File
@@ -1,12 +1,14 @@
import { Elysia } from 'elysia'
import authRouter from '~/modules/auth/router'
import userRouter from '~/modules/user/router'
// import authRouter from '~/modules/auth/router'
// import userRouter from '~/modules/user/router'
import deviceRouter from '~/modules/device/router'
export const router = new Elysia({
name: 'codebase.router',
})
.use(authRouter)
.use(userRouter)
// .use(authRouter)
// .use(userRouter)
.use(deviceRouter)
.get('/', () => ({
message: 'This service is running as expected.',
}))
@@ -0,0 +1,18 @@
export const deviceErrorResponseMap: Record<string, { status: number, response: { status: 'invalid_signature' | 'invalid_api_key' | 'replay_attack_detected' | 'invalid_request' | 'internal_error', message: string } }> = {
invalid_signature: {
status: 401,
response: { status: 'invalid_signature', message: 'Signature mismatch' },
},
invalid_api_key: {
status: 401,
response: { status: 'invalid_api_key', message: 'API Key is not valid' },
},
replay_attack_detected: {
status: 403,
response: { status: 'replay_attack_detected', message: 'Timestamp or nonce is invalid/has been reused' },
},
invalid_request: {
status: 400,
response: { status: 'invalid_request', message: 'Invalid request format' },
},
}
+1
View File
@@ -6,6 +6,7 @@ import { DataAlreadyExistsError } from './DataAlreadyExistsError'
export * from './FieldValidationError'
export * from './UnauthenticatedError'
export * from './DataAlreadyExistsError'
export * from './DeviceErrorResponseMap'
export { InternalServerError } from 'elysia'
export default {
+39
View File
@@ -0,0 +1,39 @@
// Clock skew window: ±5 minutes
const CLOCK_SKEW_MS = 5 * 60 * 1000
export const deviceGuard = () => ({
beforeHandle(context: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { headers, set } = context as any
// Validate Content-Type
if (headers['content-type'] !== 'application/json') {
set.status = 400
throw new Error('invalid_request')
}
// Validate X-API-Key
const apiKey = headers['x-api-key']
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') {
set.status = 401
throw new Error('invalid_api_key')
}
// Validate X-Timestamp
const timestampStr = headers['x-timestamp']
if (!timestampStr) {
set.status = 403
throw new Error('replay_attack_detected')
}
const timestamp = new Date(timestampStr)
if (isNaN(timestamp.getTime()) || timestampStr !== timestamp.toISOString()) {
set.status = 403
throw new Error('replay_attack_detected')
}
const now = new Date()
const diff = Math.abs(now.getTime() - timestamp.getTime())
if (diff > CLOCK_SKEW_MS) {
set.status = 403
throw new Error('replay_attack_detected')
}
},
})
+34
View File
@@ -0,0 +1,34 @@
import { verify } from '~/helpers/signature'
import { DeviceCommandBody } from '../schema'
export default abstract class CommandService {
static async sendCommand(body: DeviceCommandBody, headers: Record<string, string | undefined>): Promise<{
status: 'success'
message: 'Command accepted'
receivedAt: string
}> {
const apiKey = headers['x-api-key']
if (!apiKey) {
throw new Error('invalid_api_key')
}
// For now, use apiKey as secretKey since DB validation is skipped
const secretKey = apiKey
// Remove signature from body for verification
const { signature, ...bodyWithoutSignature } = body
const isValid = verify(bodyWithoutSignature, signature, secretKey)
if (!isValid) {
throw new Error('invalid_signature')
}
// TODO: Check nonce for replay attack when DB is implemented
return {
status: 'success',
message: 'Command accepted',
receivedAt: new Date().toISOString(),
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Elysia } from 'elysia'
import { deviceGuard } from '~/middlewares/deviceGuard'
import { deviceCommandBody, deviceCommandResponseSchema } from './schema'
import CommandService from './commands/service'
import { deviceErrorResponseMap } from '~/helpers/errors'
export const router = new Elysia({
name: 'modules.device',
detail: { tags: ['Device'] },
prefix: '/device/v1',
})
.guard(deviceGuard())
.post('/command', async ({ body, headers, set }) => {
try {
const result = await CommandService.sendCommand(body, headers)
return result
} catch (error) {
if (error instanceof Error && error.message in deviceErrorResponseMap) {
const { status, response } = deviceErrorResponseMap[error.message]
set.status = status
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return response as any
}
set.status = 500
return { status: 'internal_error', message: 'Server error' }
}
}, {
body: deviceCommandBody,
response: deviceCommandResponseSchema,
detail: {
security: [
{ apiKey: [] },
{ timestamp: [] },
{ signature: [] },
],
},
})
export default router
+52
View File
@@ -0,0 +1,52 @@
import { z } from 'zod'
// Request body schema
export const deviceCommandBody = z.object({
merchantName: z.string(),
floorName: z.string(),
unitNumber: z.string(),
deviceName: z.string(),
roomName: z.string(),
deviceType: z.string(),
commandType: z.string(),
towerNumber: z.string(),
timestamp: z.string().datetime(), // ISO8601
nonce: z.string(),
payload: z.object({
AC: z.string(),
Temp: z.number(),
}),
signature: z.string(),
})
export type DeviceCommandBody = z.infer<typeof deviceCommandBody>
// Response schemas
export const deviceCommandResponseSchema = {
200: z.object({
status: z.literal('success'),
message: z.literal('Command accepted'),
receivedAt: z.string().datetime(),
}),
400: z.object({
status: z.literal('invalid_request'),
message: z.literal('Invalid request format'),
}),
401: z.union([
z.object({
status: z.literal('invalid_signature'),
message: z.literal('Signature mismatch'),
}),
z.object({
status: z.literal('invalid_api_key'),
message: z.literal('API Key is not valid'),
}),
]),
403: z.object({
status: z.literal('replay_attack_detected'),
message: z.literal('Timestamp or nonce is invalid/has been reused'),
}),
500: z.object({
status: z.literal('internal_error'),
message: z.literal('Server error'),
}),
}
+18 -4
View File
@@ -1,6 +1,7 @@
import { openapi } from '@elysiajs/openapi'
import { OpenAPIV3 } from 'openapi-types'
import { PORT } from '~/config'
import * as z from 'zod'
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'>
@@ -8,14 +9,16 @@ type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGro
export const swaggerPlugin = openapi({
path: '/swagger',
provider: 'scalar',
mapJsonSchema: {
zod: z.toJSONSchema,
},
documentation: {
'info': {
title: 'Backend node Skyrain API Documentation',
version: 'v1.0.0',
},
'tags': [
{ name: 'Authentication', description: 'Authentication API' },
{ name: 'Users', description: 'User API' },
{ name: 'Device', description: 'Device API' },
],
'servers': [
{
@@ -33,7 +36,7 @@ export const swaggerPlugin = openapi({
],
// --- Grouping di atas tag ---
'x-tagGroups': [
{ name: 'Auth & IAM', tags: ['Authentication', 'Users'] },
{ name: 'Device', tags: ['Device'] },
],
'components': {
securitySchemes: {
@@ -46,8 +49,19 @@ export const swaggerPlugin = openapi({
scheme: 'bearer',
bearerFormat: 'JWT',
},
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API key for authentication',
},
timestamp: {
type: 'apiKey',
in: 'header',
name: 'X-Timestamp',
description: 'UTC timestamp in ISO8601 format',
},
},
},
'security': [{ bearerAuth: [] }],
} as OpenApiDocumentation,
})