MVP for API Key authentication

This commit is contained in:
ian
2025-12-03 14:51:59 +07:00
parent 9413c442df
commit 347e0192e2
17 changed files with 733 additions and 49 deletions
+2
View File
@@ -1,9 +1,11 @@
import { Elysia } from 'elysia' import { Elysia } from 'elysia'
import deviceRouter from '~/modules/device/router' import deviceRouter from '~/modules/device/router'
import authRouter from '~/modules/auth/router'
export const router = new Elysia({ export const router = new Elysia({
name: 'codebase.router', name: 'codebase.router',
}) })
.use(authRouter)
.use(deviceRouter) .use(deviceRouter)
.get('/', () => ({ .get('/', () => ({
message: 'This service is running as expected.', message: 'This service is running as expected.',
+6
View File
@@ -27,6 +27,12 @@ export const jwt = {
}, },
} }
export const apiAuth = {
apiPrefix: process.env.AUTH_API_KEY_PREFIX || 'ak_prod',
secretPrefix: process.env.AUTH_SECRET_KEY_PREFIX || 'sk_prod',
skewSeconds: Number(process.env.API_KEY_SKEW_SECONDS || 300),
}
export const s3Configs = { export const s3Configs = {
config: { config: {
region: process.env.S3_REGION || 'us-east-1', region: process.env.S3_REGION || 'us-east-1',
@@ -0,0 +1,62 @@
CREATE OR REPLACE FUNCTION generate_weekly_id(p_prefix text)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
v_yy smallint;
v_ww smallint;
v_seq integer;
BEGIN
SELECT
EXTRACT(ISOYEAR FROM CURRENT_DATE)::smallint,
EXTRACT(WEEK FROM CURRENT_DATE)::smallint
INTO v_yy, v_ww;
INSERT INTO weekly_id_counters (prefix, yy, ww, last_value)
VALUES (p_prefix, v_yy, v_ww, 1)
ON CONFLICT (prefix, yy, ww)
DO UPDATE SET last_value = weekly_id_counters.last_value + 1
RETURNING last_value INTO v_seq;
RETURN format(
'%s-%02s%02s%04s',
p_prefix,
v_yy % 100,
v_ww,
v_seq
);
END;
$$;
--> statement-breakpoint
CREATE TABLE "merchant_nonces" (
"merchant_id" varchar(64) NOT NULL,
"nonce" varchar(128) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "merchant_nonces_merchant_id_nonce_pk" PRIMARY KEY("merchant_id","nonce")
);
--> statement-breakpoint
CREATE TABLE "merchants" (
"merchant_id" text PRIMARY KEY DEFAULT generate_weekly_id('MERCH') NOT NULL,
"merchant_name" text NOT NULL,
"contact_email" text NOT NULL,
"contact_phone" text NOT NULL,
"api_key" text NOT NULL,
"secret_key" text NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"revoked_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "weekly_id_counters" (
"prefix" text NOT NULL,
"yy" smallint NOT NULL,
"ww" smallint NOT NULL,
"last_value" integer NOT NULL,
CONSTRAINT "weekly_id_counters_prefix_yy_ww_pk" PRIMARY KEY("prefix","yy","ww")
);
--> statement-breakpoint
ALTER TABLE "merchant_nonces" ADD CONSTRAINT "merchant_nonces_merchant_id_merchants_merchant_id_fk" FOREIGN KEY ("merchant_id") REFERENCES "public"."merchants"("merchant_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "merchants_secret_key_idx" ON "merchants" USING btree ("secret_key");--> statement-breakpoint
CREATE INDEX "merchants_is_active_idx" ON "merchants" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "merchants_contact_email_idx" ON "merchants" USING btree ("contact_email");--> statement-breakpoint
CREATE INDEX "merchants_contact_phone_idx" ON "merchants" USING btree ("contact_phone");
@@ -0,0 +1,358 @@
{
"id": "64d5c135-050b-402c-9bb5-e2eff6b17d93",
"prevId": "7f01d0a9-3f79-476c-b885-b83b3191bdf8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.merchant_nonces": {
"name": "merchant_nonces",
"schema": "",
"columns": {
"merchant_id": {
"name": "merchant_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"nonce": {
"name": "nonce",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"merchant_nonces_merchant_id_merchants_merchant_id_fk": {
"name": "merchant_nonces_merchant_id_merchants_merchant_id_fk",
"tableFrom": "merchant_nonces",
"tableTo": "merchants",
"columnsFrom": [
"merchant_id"
],
"columnsTo": [
"merchant_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"merchant_nonces_merchant_id_nonce_pk": {
"name": "merchant_nonces_merchant_id_nonce_pk",
"columns": [
"merchant_id",
"nonce"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.merchants": {
"name": "merchants",
"schema": "",
"columns": {
"merchant_id": {
"name": "merchant_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "generate_weekly_id('MERCH')"
},
"merchant_name": {
"name": "merchant_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"contact_email": {
"name": "contact_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"contact_phone": {
"name": "contact_phone",
"type": "text",
"primaryKey": false,
"notNull": true
},
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"secret_key": {
"name": "secret_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"merchants_secret_key_idx": {
"name": "merchants_secret_key_idx",
"columns": [
{
"expression": "secret_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"merchants_is_active_idx": {
"name": "merchants_is_active_idx",
"columns": [
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"merchants_contact_email_idx": {
"name": "merchants_contact_email_idx",
"columns": [
{
"expression": "contact_email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"merchants_contact_phone_idx": {
"name": "merchants_contact_phone_idx",
"columns": [
{
"expression": "contact_phone",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "varchar(127)",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"status": {
"name": "status",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.weekly_id_counters": {
"name": "weekly_id_counters",
"schema": "",
"columns": {
"prefix": {
"name": "prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"yy": {
"name": "yy",
"type": "smallint",
"primaryKey": false,
"notNull": true
},
"ww": {
"name": "ww",
"type": "smallint",
"primaryKey": false,
"notNull": true
},
"last_value": {
"name": "last_value",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"weekly_id_counters_prefix_yy_ww_pk": {
"name": "weekly_id_counters_prefix_yy_ww_pk",
"columns": [
"prefix",
"yy",
"ww"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.role": {
"name": "role",
"schema": "public",
"values": [
"admin",
"visitor",
"user",
"volunteer"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1764313178750, "when": 1764313178750,
"tag": "1764313178_flimsy_tarantula", "tag": "1764313178_flimsy_tarantula",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764747189387,
"tag": "1764747189_brave_kree",
"breakpoints": true
} }
] ]
} }
+6
View File
@@ -1,4 +1,6 @@
import users, * as userSchema from './schema/users' import users, * as userSchema from './schema/users'
import merchants, * as merchantSchema from './schema/merchants'
import merchantNonces, * as merchantNonceSchema from './schema/merchant_nonces'
// relation // relation
// import * as relations from './relations' // import * as relations from './relations'
@@ -6,10 +8,14 @@ import users, * as userSchema from './schema/users'
export const schema = { export const schema = {
// ...relations, // ...relations,
...userSchema, ...userSchema,
...merchantSchema,
...merchantNonceSchema,
} }
export const table = { export const table = {
users, users,
merchants,
merchantNonces,
} as const } as const
export type Table = typeof table export type Table = typeof table
+15
View File
@@ -0,0 +1,15 @@
// db/schema.ts
import { pgTable, varchar, timestamp, primaryKey } from 'drizzle-orm/pg-core'
import merchants from './merchants'
export const merchantNonces = pgTable('merchant_nonces', {
merchantId: varchar('merchant_id', { length: 64 })
.notNull()
.references(() => merchants.merchantId, { onDelete: 'cascade' }),
nonce: varchar('nonce', { length: 128 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => [
primaryKey({ columns: [t.merchantId, t.nonce] }),
])
export default merchantNonces
+22
View File
@@ -0,0 +1,22 @@
// db/schema.ts
import { sql } from 'drizzle-orm'
import { pgTable, text, boolean, timestamp, index } from 'drizzle-orm/pg-core'
export const merchants = pgTable('merchants', {
merchantId: text('merchant_id').primaryKey().notNull().default(sql<string>`generate_weekly_id('MERCH')`),
merchantName: text('merchant_name').notNull(),
contactEmail: text('contact_email').notNull(),
contactPhone: text('contact_phone').notNull(),
apiKey: text('api_key').notNull(),
secretKey: text('secret_key').notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
}, (t) => [
index('merchants_secret_key_idx').on(t.secretKey),
index('merchants_is_active_idx').on(t.isActive),
index('merchants_contact_email_idx').on(t.contactEmail),
index('merchants_contact_phone_idx').on(t.contactPhone),
])
export default merchants
+14
View File
@@ -0,0 +1,14 @@
import { integer, pgTable, primaryKey, smallint, text } from 'drizzle-orm/pg-core'
export const weeklyIdCounters = pgTable(
'weekly_id_counters',
{
prefix: text('prefix').notNull(),
yy: smallint('yy').notNull(),
ww: smallint('ww').notNull(),
lastValue: integer('last_value').notNull(),
},
(t) => [
primaryKey({ columns: [t.prefix, t.yy, t.ww] }),
],
)
+36 -19
View File
@@ -1,7 +1,8 @@
import { createHmac, timingSafeEqual } from 'node:crypto' import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { apiAuth } from '~/config'
/** /**
* Canonicalizes an object by recursively removing 'signature' properties, * Canonicalizes an object by recursively removing 'signature', 'nonce', and 'timestamp' properties,
* sorting object keys lexicographically, preserving array order, * sorting object keys lexicographically, preserving array order,
* omitting undefined, functions, and symbols from objects, * omitting undefined, functions, and symbols from objects,
* converting undefined or unsupported array elements to null, * converting undefined or unsupported array elements to null,
@@ -10,7 +11,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
* Throws on circular references or unsupported objects. * Throws on circular references or unsupported objects.
* *
* @param data The data to canonicalize. * @param data The data to canonicalize.
* @returns The canonicalized, signature-free structure. * @returns The canonicalized structure with signature, nonce, and timestamp omitted.
*/ */
export function canonicalizeObject<T = unknown>(data: T): unknown { export function canonicalizeObject<T = unknown>(data: T): unknown {
const visited = new Set<unknown>() const visited = new Set<unknown>()
@@ -60,7 +61,7 @@ export function canonicalizeObject<T = unknown>(data: T): unknown {
const result: Record<string, unknown> = {} const result: Record<string, unknown> = {}
for (const key in obj) { for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key) && typeof key === 'string' && key !== 'signature') { if (Object.prototype.hasOwnProperty.call(obj, key) && typeof key === 'string' && key !== 'signature' && key !== 'nonce' && key !== 'timestamp') {
const val = obj[key] const val = obj[key]
if (val !== undefined && typeof val !== 'function' && typeof val !== 'symbol') { if (val !== undefined && typeof val !== 'function' && typeof val !== 'symbol') {
result[key] = recurse(val) result[key] = recurse(val)
@@ -109,21 +110,13 @@ export function canonicalize(data: unknown): string {
* Signs the data using HMAC-SHA256 and returns the signature as lowercase hex. * Signs the data using HMAC-SHA256 and returns the signature as lowercase hex.
* *
* @param data The data to sign. * @param data The data to sign.
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY. * @param secretKey The secret key.
* @returns The signature. * @returns The signature.
*/ */
export function sign(data: unknown, secretKey?: string): string { export function sign(data: unknown, secretKey: string): string {
if (!secretKey) { return createHmac('sha256', secretKey)
secretKey = process.env.SECRET_KEY .update(canonicalize(data), 'utf8')
if (!secretKey || secretKey.trim() === '') { .digest('hex')
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')
} }
/** /**
@@ -131,10 +124,10 @@ export function sign(data: unknown, secretKey?: string): string {
* *
* @param data The data to verify. * @param data The data to verify.
* @param signature The expected signature. * @param signature The expected signature.
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY. * @param secretKey The secret key.
* @returns True if the signature is valid, false otherwise. * @returns True if the signature is valid, false otherwise.
*/ */
export function verify(data: unknown, signature: string, secretKey?: string): boolean { export function verify(data: unknown, signature: string, secretKey: string): boolean {
try { try {
const computed = sign(data, secretKey) const computed = sign(data, secretKey)
const computedBuf = Buffer.from(computed, 'hex') const computedBuf = Buffer.from(computed, 'hex')
@@ -148,6 +141,30 @@ export function verify(data: unknown, signature: string, secretKey?: string): bo
} }
} }
/**
* Generates an API key.
*
* @param bytes The number of bytes to generate.
* @returns The secret key.
*/
export function generateApiKey(bytes = 16): string {
const raw = randomBytes(bytes)
const hex = raw.toString('base64url')
return `${apiAuth.apiPrefix}:${hex}`
}
/**
* Generates a secret key for HMAC-SHA256.
*
* @param bytes The number of bytes to generate.
* @returns The secret key.
*/
export function generateSecretKey(bytes = 32): string {
const raw = randomBytes(bytes)
const hex = raw.toString('base64url')
return `${apiAuth.secretPrefix}:${hex}`
}
// console.table([ // console.table([
// { act: 'command', signature: sign( // { act: 'command', signature: sign(
// { // {
+64 -29
View File
@@ -1,33 +1,68 @@
// Clock skew window: ±5 minutes import Elysia, { t } from 'elysia'
// const CLOCK_SKEW_MS = 5 * 60 * 1000 import { db, table as $t } from '~/db'
import { and, eq } from 'drizzle-orm'
import { apiAuth } from '~/config'
import merchantNonces from '~/db/schema/merchant_nonces'
import { verify } from '~/helpers/signature'
import { UnauthenticatedError } from '~/helpers/errors'
export const deviceGuard = () => ({ export const apiKey = new Elysia().macro({
beforeHandle(context: unknown) { verifyKey: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any headers: t.Object({
const { headers, set } = context as any 'x-api-key': t.String(),
// Validate X-API-Key 'x-nonce': t.String(),
const apiKey = headers['x-api-key'] 'x-signature': t.String(),
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') { 'x-timestamp': t.Number(),
set.status = 401 }),
throw new Error('invalid_api_key')
}
// Validate X-Timestamp async beforeHandle({ headers, body }) {
// const timestampStr = headers['x-timestamp'] const apiKey = headers['x-api-key']
// if (!timestampStr) { const nonce = headers['x-nonce']
// set.status = 403 const signature = headers['x-signature']
// throw new Error('replay_attack_detected') const timestamp = headers['x-timestamp']
// } if (!apiKey || !nonce || !signature || !timestamp) {
// const timestamp = new Date(timestampStr) throw new UnauthenticatedError('Missing auth headers')
// if (isNaN(timestamp.getTime()) || timestampStr !== timestamp.toISOString()) { }
// set.status = 403
// throw new Error('replay_attack_detected') const tsNum = Number(timestamp)
// } if (!Number.isFinite(tsNum)) {
// const now = new Date() throw new UnauthenticatedError('Invalid timestamp')
// const diff = Math.abs(now.getTime() - timestamp.getTime()) }
// if (diff > CLOCK_SKEW_MS) { const nowSec = Math.floor(Date.now() / 1000)
// set.status = 403 if (Math.abs(nowSec - tsNum) > apiAuth.skewSeconds) {
// throw new Error('replay_attack_detected') throw new UnauthenticatedError('Timestamp out of range')
// } }
const merchant = await db.query.merchants.findFirst({
where: (t, { and, eq }) => and(
eq(t.apiKey, apiKey),
eq(t.isActive, true),
),
})
if (!merchant) {
throw new UnauthenticatedError('Invalid API key')
}
if (!nonce) {
throw new UnauthenticatedError('Nonce is required')
}
const existingNonce = await db.$count($t.merchantNonces, and(
eq($t.merchantNonces.merchantId, merchant.merchantId),
eq($t.merchantNonces.nonce, nonce),
))
if (existingNonce > 0) {
throw new UnauthenticatedError('API replay detected')
}
db.insert(merchantNonces).values({
merchantId: merchant.merchantId,
nonce,
})
if (!verify(body, signature, merchant.secretKey)) {
throw new UnauthenticatedError('Invalid signature')
}
},
}, },
}) })
+40
View File
@@ -0,0 +1,40 @@
import { db, table } from '~/db'
import { generateApiKey, generateSecretKey } from '~/helpers/signature'
import { DataAlreadyExistsError } from '~/helpers/errors'
import { CreateMerchantBody } from '../schema'
import { checkMerchantExists } from '../queries/query'
export async function createMerchant(body: CreateMerchantBody) {
// Check if merchant with same email or phone exists
const exists = await checkMerchantExists(body.contactEmail, body.contactPhone)
if (exists) {
throw new DataAlreadyExistsError('Merchant with this email or phone already exists')
}
// Generate keys
const apiKey = generateApiKey()
const secretKey = generateSecretKey()
// Insert new merchant
const result = await db
.insert(table.merchants)
.values({
merchantName: body.merchantName,
contactEmail: body.contactEmail,
contactPhone: body.contactPhone,
apiKey,
secretKey,
})
.returning({
merchantId: table.merchants.merchantId,
apiKey: table.merchants.apiKey,
secretKey: table.merchants.secretKey,
createdAt: table.merchants.createdAt,
})
if (result.length === 0) {
throw new Error('Failed to create merchant')
}
return result[0]
}
+12
View File
@@ -0,0 +1,12 @@
import { createMerchant } from './command'
import { CreateMerchantBody } from '../schema'
export default abstract class CreateMerchantService {
static async createMerchant(body: CreateMerchantBody): Promise<{
merchantId: string
apiKey: string
secretKey: string
}> {
return await createMerchant(body)
}
}
+11
View File
@@ -0,0 +1,11 @@
import { eq, or } from 'drizzle-orm'
import { db, table } from '~/db'
export async function checkMerchantExists(email: string, phone: string) {
const existingMerchant = await db.$count(table.merchants, or(
eq(table.merchants.contactEmail, email),
eq(table.merchants.contactPhone, phone),
))
return existingMerchant > 0
}
+41
View File
@@ -0,0 +1,41 @@
import { Elysia } from 'elysia'
import { createMerchantBody, createMerchantResponseSchema } from './schema'
import CreateMerchantService from './commands/service'
import { basicAuthMiddleware } from '~/middlewares/basicAuth'
import { DataAlreadyExistsError } from '~/helpers/errors'
export const router = new Elysia({
name: 'modules.auth',
detail: { tags: ['Auth'] },
prefix: '/auth',
})
.post('/get-key', async ({ body, headers, set }) => {
basicAuthMiddleware(headers, set)
try {
const result = await CreateMerchantService.createMerchant(body)
return {
status: 'success' as const,
message: 'Merchant created successfully' as const,
data: result,
}
} catch (error) {
if (error instanceof DataAlreadyExistsError) {
set.status = 409
return {
status: 'conflict' as const,
message: 'Merchant with this email or phone already exists' as const,
}
}
set.status = 500
return {
status: 'internal_error' as const,
message: 'Server error' as const,
}
}
}, {
body: createMerchantBody,
response: createMerchantResponseSchema,
})
export default router
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod'
// Request body schema
export const createMerchantBody = z.object({
merchantName: z.string(),
contactEmail: z.string().email(),
contactPhone: z.string(),
})
export type CreateMerchantBody = z.infer<typeof createMerchantBody>
// Response schemas
export const createMerchantResponseSchema = {
200: z.object({
status: z.literal('success'),
message: z.literal('Merchant created successfully'),
data: z.object({
merchantId: z.string(),
apiKey: z.string(),
secretKey: z.string(),
}),
}),
400: z.object({
status: z.literal('invalid_request'),
message: z.literal('Invalid request format'),
}),
409: z.object({
status: z.literal('conflict'),
message: z.literal('Merchant with this email or phone already exists'),
}),
500: z.object({
status: z.literal('internal_error'),
message: z.literal('Server error'),
}),
}
+3 -1
View File
@@ -6,7 +6,7 @@ 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'> type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGroups>>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>
export const swaggerPlugin = openapi({ export const swaggerPlugin = openapi({
path: '/swagger', path: '/docs',
provider: 'scalar', provider: 'scalar',
mapJsonSchema: { mapJsonSchema: {
zod: z.toJSONSchema, zod: z.toJSONSchema,
@@ -17,10 +17,12 @@ export const swaggerPlugin = openapi({
version: 'v1.0.0', version: 'v1.0.0',
}, },
'tags': [ 'tags': [
{ name: 'Auth', description: 'Auth API' },
{ name: 'Device', description: 'Device API' }, { name: 'Device', description: 'Device API' },
], ],
// --- Grouping di atas tag --- // --- Grouping di atas tag ---
'x-tagGroups': [ 'x-tagGroups': [
{ name: 'Auth', tags: ['Auth'] },
{ name: 'Device', tags: ['Device'] }, { name: 'Device', tags: ['Device'] },
], ],
'components': { 'components': {