From 9413c442dfaa76969a9a7bcb912e9b97ff7f6427 Mon Sep 17 00:00:00 2001 From: Ian Mustafa Date: Fri, 28 Nov 2025 15:45:51 +0700 Subject: [PATCH 1/3] WIP: auth user creation --- bun.lock | 47 ++++++++++++++++++- drizzle.config.ts | 2 +- package.json | 2 + .../1764313178_flimsy_tarantula.sql | 4 +- .../migrations/meta/1764313178_snapshot.json | 10 ++-- .../db/migrations}/meta/_journal.json | 4 +- src/helpers/signature.ts | 6 ++- src/middlewares/apiKeyAuth.ts | 33 +++++++++++++ 8 files changed, 95 insertions(+), 13 deletions(-) rename drizzle/1759370311_heavy_gressill.sql => src/db/migrations/1764313178_flimsy_tarantula.sql (76%) rename drizzle/meta/1759370311_snapshot.json => src/db/migrations/meta/1764313178_snapshot.json (94%) rename {drizzle => src/db/migrations}/meta/_journal.json (66%) create mode 100644 src/middlewares/apiKeyAuth.ts diff --git a/bun.lock b/bun.lock index 4a32064..d0f5f4d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "backend-node-mbg", @@ -35,7 +36,9 @@ "devDependencies": { "@eslint/js": "^9.35.0", "@stylistic/eslint-plugin": "^5.3.1", + "@types/bun": "^1.3.3", "@types/lodash-es": "^4.17.12", + "@types/node": "^24.10.1", "bun-types": "latest", "eslint": "^9.35.0", "globals": "^16.4.0", @@ -539,6 +542,8 @@ "@types/aws-lambda": ["@types/aws-lambda@8.10.152", "", {}, "sha512-soT/c2gYBnT5ygwiHPmd9a1bftj462NWVk2tKCc1PYHSIacB2UwbTS2zYG4jzag1mRDuzg/OjtxQjQ2NKRB6Rw=="], + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@types/bunyan": ["@types/bunyan@1.8.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], @@ -555,7 +560,7 @@ "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], - "@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@types/oracledb": ["@types/oracledb@6.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ=="], @@ -1047,7 +1052,7 @@ "uint8array-extras": ["uint8array-extras@1.4.1", "", {}, "sha512-+NWHrac9dvilNgme+gP4YrBSumsaMZP0fNBtXXFIf33RLLKEcBUKaQZ7ULUbS0sBfcjxIZ4V96OTRkCbM7hxpw=="], - "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1191,12 +1196,30 @@ "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], + "@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "@types/bunyan/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "@types/connect/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "@types/memcached/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "@types/mysql/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "@types/oracledb/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "@types/pg/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + + "@types/tedious/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + "bun-types/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + "eslint/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1205,6 +1228,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "protobufjs/@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + "protobufjs/long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -1301,12 +1326,30 @@ "@scalar/themes/@scalar/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@types/bunyan/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@types/connect/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@types/memcached/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@types/mysql/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@types/oracledb/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@types/pg/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@types/tedious/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "eslint/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "protobufjs/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], diff --git a/drizzle.config.ts b/drizzle.config.ts index ea6d2de..bc49f8c 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,7 +3,7 @@ import { dbDsn } from '~/config' export default defineConfig({ schema: './src/db/schema/*', - out: './drizzle', + out: './src/db/migrations', dialect: 'postgresql', migrations: { prefix: 'unix', diff --git a/package.json b/package.json index 14371db..63b70f2 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "devDependencies": { "@eslint/js": "^9.35.0", "@stylistic/eslint-plugin": "^5.3.1", + "@types/bun": "^1.3.3", "@types/lodash-es": "^4.17.12", + "@types/node": "^24.10.1", "bun-types": "latest", "eslint": "^9.35.0", "globals": "^16.4.0", diff --git a/drizzle/1759370311_heavy_gressill.sql b/src/db/migrations/1764313178_flimsy_tarantula.sql similarity index 76% rename from drizzle/1759370311_heavy_gressill.sql rename to src/db/migrations/1764313178_flimsy_tarantula.sql index 2ee544d..296a37a 100644 --- a/drizzle/1759370311_heavy_gressill.sql +++ b/src/db/migrations/1764313178_flimsy_tarantula.sql @@ -1,6 +1,6 @@ -CREATE TYPE "public"."role" AS ENUM('admin', 'visitor', 'user');--> statement-breakpoint +CREATE TYPE "public"."role" AS ENUM('admin', 'visitor', 'user', 'volunteer');--> statement-breakpoint CREATE TABLE "users" ( - "id" "ulid" PRIMARY KEY DEFAULT gen_ulid() NOT NULL, + "id" serial PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "email" varchar(255), "phone" varchar(255), diff --git a/drizzle/meta/1759370311_snapshot.json b/src/db/migrations/meta/1764313178_snapshot.json similarity index 94% rename from drizzle/meta/1759370311_snapshot.json rename to src/db/migrations/meta/1764313178_snapshot.json index a75119f..eddbc89 100644 --- a/drizzle/meta/1759370311_snapshot.json +++ b/src/db/migrations/meta/1764313178_snapshot.json @@ -1,5 +1,5 @@ { - "id": "16065bc5-5f72-44e1-9a45-2f939dd8866a", + "id": "7f01d0a9-3f79-476c-b885-b83b3191bdf8", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -10,10 +10,9 @@ "columns": { "id": { "name": "id", - "type": "ulid", + "type": "serial", "primaryKey": true, - "notNull": true, - "default": "gen_ulid()" + "notNull": true }, "name": { "name": "name", @@ -110,7 +109,8 @@ "values": [ "admin", "visitor", - "user" + "user", + "volunteer" ] } }, diff --git a/drizzle/meta/_journal.json b/src/db/migrations/meta/_journal.json similarity index 66% rename from drizzle/meta/_journal.json rename to src/db/migrations/meta/_journal.json index 28c5ffb..aa0bf7b 100644 --- a/drizzle/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1759370311281, - "tag": "1759370311_heavy_gressill", + "when": 1764313178750, + "tag": "1764313178_flimsy_tarantula", "breakpoints": true } ] diff --git a/src/helpers/signature.ts b/src/helpers/signature.ts index 4f1981d..3981bca 100644 --- a/src/helpers/signature.ts +++ b/src/helpers/signature.ts @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from 'crypto' +import { createHmac, timingSafeEqual } from 'node:crypto' /** * Canonicalizes an object by recursively removing 'signature' properties, @@ -8,6 +8,7 @@ import { createHmac, timingSafeEqual } from 'crypto' * 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. */ @@ -96,6 +97,7 @@ export function canonicalizeObject(data: T): unknown { /** * Returns the canonical JSON string of the data. + * * @param data The data to canonicalize. * @returns The canonical JSON string. */ @@ -105,6 +107,7 @@ export function canonicalize(data: unknown): string { /** * 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. @@ -125,6 +128,7 @@ export function sign(data: unknown, secretKey?: string): string { /** * 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. diff --git a/src/middlewares/apiKeyAuth.ts b/src/middlewares/apiKeyAuth.ts new file mode 100644 index 0000000..85e7614 --- /dev/null +++ b/src/middlewares/apiKeyAuth.ts @@ -0,0 +1,33 @@ +// 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 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') + // } + }, +}) From 347e0192e2993586a0b5fc18786b737bb1f34908 Mon Sep 17 00:00:00 2001 From: Ian Mustafa Date: Wed, 3 Dec 2025 14:51:59 +0700 Subject: [PATCH 2/3] MVP for API Key authentication --- src/app/router.ts | 2 + src/config/index.ts | 6 + src/db/migrations/1764747189_brave_kree.sql | 62 +++ .../migrations/meta/1764747189_snapshot.json | 358 ++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/model.ts | 6 + src/db/schema/merchant_nonces.ts | 15 + src/db/schema/merchants.ts | 22 ++ src/db/schema/weekly_id_counters.ts | 14 + src/helpers/signature.ts | 55 ++- src/middlewares/apiKeyAuth.ts | 93 +++-- src/modules/auth/commands/command.ts | 40 ++ src/modules/auth/commands/service.ts | 12 + src/modules/auth/queries/query.ts | 11 + src/modules/auth/router.ts | 41 ++ src/modules/auth/schema.ts | 34 ++ src/plugins/swagger.ts | 4 +- 17 files changed, 733 insertions(+), 49 deletions(-) create mode 100644 src/db/migrations/1764747189_brave_kree.sql create mode 100644 src/db/migrations/meta/1764747189_snapshot.json create mode 100644 src/db/schema/merchant_nonces.ts create mode 100644 src/db/schema/merchants.ts create mode 100644 src/db/schema/weekly_id_counters.ts create mode 100644 src/modules/auth/commands/command.ts create mode 100644 src/modules/auth/commands/service.ts create mode 100644 src/modules/auth/queries/query.ts create mode 100644 src/modules/auth/router.ts create mode 100644 src/modules/auth/schema.ts diff --git a/src/app/router.ts b/src/app/router.ts index 87091fa..c3057e2 100644 --- a/src/app/router.ts +++ b/src/app/router.ts @@ -1,9 +1,11 @@ import { Elysia } from 'elysia' import deviceRouter from '~/modules/device/router' +import authRouter from '~/modules/auth/router' export const router = new Elysia({ name: 'codebase.router', }) + .use(authRouter) .use(deviceRouter) .get('/', () => ({ message: 'This service is running as expected.', diff --git a/src/config/index.ts b/src/config/index.ts index b92d2bf..0797ce8 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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 = { config: { region: process.env.S3_REGION || 'us-east-1', diff --git a/src/db/migrations/1764747189_brave_kree.sql b/src/db/migrations/1764747189_brave_kree.sql new file mode 100644 index 0000000..b95892c --- /dev/null +++ b/src/db/migrations/1764747189_brave_kree.sql @@ -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"); diff --git a/src/db/migrations/meta/1764747189_snapshot.json b/src/db/migrations/meta/1764747189_snapshot.json new file mode 100644 index 0000000..8e2aea8 --- /dev/null +++ b/src/db/migrations/meta/1764747189_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index aa0bf7b..325de0a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1764313178750, "tag": "1764313178_flimsy_tarantula", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1764747189387, + "tag": "1764747189_brave_kree", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/model.ts b/src/db/model.ts index e716bbe..b3ca0be 100644 --- a/src/db/model.ts +++ b/src/db/model.ts @@ -1,4 +1,6 @@ import users, * as userSchema from './schema/users' +import merchants, * as merchantSchema from './schema/merchants' +import merchantNonces, * as merchantNonceSchema from './schema/merchant_nonces' // relation // import * as relations from './relations' @@ -6,10 +8,14 @@ import users, * as userSchema from './schema/users' export const schema = { // ...relations, ...userSchema, + ...merchantSchema, + ...merchantNonceSchema, } export const table = { users, + merchants, + merchantNonces, } as const export type Table = typeof table diff --git a/src/db/schema/merchant_nonces.ts b/src/db/schema/merchant_nonces.ts new file mode 100644 index 0000000..4803aba --- /dev/null +++ b/src/db/schema/merchant_nonces.ts @@ -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 diff --git a/src/db/schema/merchants.ts b/src/db/schema/merchants.ts new file mode 100644 index 0000000..0976138 --- /dev/null +++ b/src/db/schema/merchants.ts @@ -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`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 diff --git a/src/db/schema/weekly_id_counters.ts b/src/db/schema/weekly_id_counters.ts new file mode 100644 index 0000000..b9c9ab1 --- /dev/null +++ b/src/db/schema/weekly_id_counters.ts @@ -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] }), + ], +) diff --git a/src/helpers/signature.ts b/src/helpers/signature.ts index 3981bca..cd9af8b 100644 --- a/src/helpers/signature.ts +++ b/src/helpers/signature.ts @@ -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, * omitting undefined, functions, and symbols from objects, * 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. * * @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(data: T): unknown { const visited = new Set() @@ -60,7 +61,7 @@ export function canonicalizeObject(data: T): unknown { const result: Record = {} 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] if (val !== undefined && typeof val !== 'function' && typeof val !== 'symbol') { 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. * * @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. */ -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') +export function sign(data: unknown, secretKey: string): string { + return createHmac('sha256', secretKey) + .update(canonicalize(data), 'utf8') + .digest('hex') } /** @@ -131,10 +124,10 @@ export function sign(data: unknown, secretKey?: string): string { * * @param data The data to verify. * @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. */ -export function verify(data: unknown, signature: string, secretKey?: string): boolean { +export function verify(data: unknown, signature: string, secretKey: string): boolean { try { const computed = sign(data, secretKey) 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([ // { act: 'command', signature: sign( // { diff --git a/src/middlewares/apiKeyAuth.ts b/src/middlewares/apiKeyAuth.ts index 85e7614..579f85a 100644 --- a/src/middlewares/apiKeyAuth.ts +++ b/src/middlewares/apiKeyAuth.ts @@ -1,33 +1,68 @@ -// Clock skew window: ±5 minutes -// const CLOCK_SKEW_MS = 5 * 60 * 1000 +import Elysia, { t } from 'elysia' +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 = () => ({ - beforeHandle(context: unknown) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { headers, set } = context as any - // 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') - } +export const apiKey = new Elysia().macro({ + verifyKey: { + headers: t.Object({ + 'x-api-key': t.String(), + 'x-nonce': t.String(), + 'x-signature': t.String(), + 'x-timestamp': t.Number(), + }), - // 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') - // } + async beforeHandle({ headers, body }) { + const apiKey = headers['x-api-key'] + const nonce = headers['x-nonce'] + const signature = headers['x-signature'] + const timestamp = headers['x-timestamp'] + if (!apiKey || !nonce || !signature || !timestamp) { + throw new UnauthenticatedError('Missing auth headers') + } + + const tsNum = Number(timestamp) + if (!Number.isFinite(tsNum)) { + throw new UnauthenticatedError('Invalid timestamp') + } + const nowSec = Math.floor(Date.now() / 1000) + if (Math.abs(nowSec - tsNum) > apiAuth.skewSeconds) { + 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') + } + }, }, }) diff --git a/src/modules/auth/commands/command.ts b/src/modules/auth/commands/command.ts new file mode 100644 index 0000000..5b8c298 --- /dev/null +++ b/src/modules/auth/commands/command.ts @@ -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] +} diff --git a/src/modules/auth/commands/service.ts b/src/modules/auth/commands/service.ts new file mode 100644 index 0000000..925d376 --- /dev/null +++ b/src/modules/auth/commands/service.ts @@ -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) + } +} diff --git a/src/modules/auth/queries/query.ts b/src/modules/auth/queries/query.ts new file mode 100644 index 0000000..43fddb4 --- /dev/null +++ b/src/modules/auth/queries/query.ts @@ -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 +} diff --git a/src/modules/auth/router.ts b/src/modules/auth/router.ts new file mode 100644 index 0000000..c07c2b5 --- /dev/null +++ b/src/modules/auth/router.ts @@ -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 diff --git a/src/modules/auth/schema.ts b/src/modules/auth/schema.ts new file mode 100644 index 0000000..95de19d --- /dev/null +++ b/src/modules/auth/schema.ts @@ -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 + +// 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'), + }), +} diff --git a/src/plugins/swagger.ts b/src/plugins/swagger.ts index b2e75f7..81c4b25 100644 --- a/src/plugins/swagger.ts +++ b/src/plugins/swagger.ts @@ -6,7 +6,7 @@ interface OpenApiDocWithTagGroups { 'x-tagGroups': { name: string, tags: string[ type OpenApiDocumentation = Omit>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'> export const swaggerPlugin = openapi({ - path: '/swagger', + path: '/docs', provider: 'scalar', mapJsonSchema: { zod: z.toJSONSchema, @@ -17,10 +17,12 @@ export const swaggerPlugin = openapi({ version: 'v1.0.0', }, 'tags': [ + { name: 'Auth', description: 'Auth API' }, { name: 'Device', description: 'Device API' }, ], // --- Grouping di atas tag --- 'x-tagGroups': [ + { name: 'Auth', tags: ['Auth'] }, { name: 'Device', tags: ['Device'] }, ], 'components': { From 66069aa0a90f5f1cc95d9f9cd813d0f5797b37ac Mon Sep 17 00:00:00 2001 From: NurAlan Date: Wed, 3 Dec 2025 15:01:36 +0700 Subject: [PATCH 3/3] fix: bug documentation --- src/modules/auth/router.ts | 3 +++ src/plugins/swagger.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/modules/auth/router.ts b/src/modules/auth/router.ts index c07c2b5..888aa79 100644 --- a/src/modules/auth/router.ts +++ b/src/modules/auth/router.ts @@ -36,6 +36,9 @@ export const router = new Elysia({ }, { body: createMerchantBody, response: createMerchantResponseSchema, + detail: { + security: [{ basicAuth: [] }], + }, }) export default router diff --git a/src/plugins/swagger.ts b/src/plugins/swagger.ts index 81c4b25..2b35eec 100644 --- a/src/plugins/swagger.ts +++ b/src/plugins/swagger.ts @@ -27,6 +27,10 @@ export const swaggerPlugin = openapi({ ], 'components': { securitySchemes: { + basicAuth: { + type: 'http', + scheme: 'basic', + }, apiKey: { type: 'apiKey', in: 'header',