.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@suitecoffee/db",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./poolSingleton.mjs",
|
||||
"types": "./poolSingleton.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./poolSingleton.d.ts",
|
||||
"import": "./poolSingleton.mjs",
|
||||
"default": "./poolSingleton.mjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"files": [
|
||||
"poolSingleton.mjs",
|
||||
"poolSingleton.d.ts"
|
||||
]
|
||||
}
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
// packages/core/db/poolSingleton.d.ts
|
||||
// Declaraciones de tipos para @suitecoffee/db
|
||||
// Refleja el módulo ESM que expone poolCore y poolTenants (ambos Singletons)
|
||||
|
||||
import type {
|
||||
Pool,
|
||||
PoolClient,
|
||||
PoolConfig,
|
||||
QueryResult,
|
||||
QueryResultRow,
|
||||
QueryConfig
|
||||
} from 'pg';
|
||||
|
||||
export type { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow, QueryConfig };
|
||||
|
||||
// Clases modeladas según la implementación JS (no se exportan como valores en runtime,
|
||||
// pero se exponen como tipos para el consumidor que quiera tipar sus variables).
|
||||
export declare class DatabaseCore {
|
||||
/** Instancia singleton interna (solo informativa para tipado). */
|
||||
static instance?: DatabaseCore;
|
||||
|
||||
/** Pool real de `pg`. */
|
||||
connection: Pool;
|
||||
|
||||
constructor();
|
||||
|
||||
/** Ejecuta una consulta utilizando el pool. */
|
||||
query<T extends QueryResultRow = any>(
|
||||
sql: string | QueryConfig<any[]>,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>>;
|
||||
|
||||
/** Alias al `pool.connect()`; devuelve un `PoolClient`. */
|
||||
connect(): Promise<PoolClient>;
|
||||
|
||||
/** Alias al `pool.connect()`; devuelve un `PoolClient`. */
|
||||
getClient(): Promise<PoolClient>;
|
||||
|
||||
/** Cierra el pool subyacente. */
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
export declare class DatabaseTenants {
|
||||
static instance?: DatabaseTenants;
|
||||
connection: Pool;
|
||||
|
||||
constructor();
|
||||
|
||||
query<T extends QueryResultRow = any>(
|
||||
sql: string | QueryConfig<any[]>,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>>;
|
||||
|
||||
connect(): Promise<PoolClient>;
|
||||
getClient(): Promise<PoolClient>;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Singletons creados por el módulo. */
|
||||
export declare const poolCore: DatabaseCore;
|
||||
export declare const poolTenants: DatabaseTenants;
|
||||
|
||||
/** Export por defecto del módulo: objeto con ambos pools. */
|
||||
declare const _default: {
|
||||
poolCore: DatabaseCore;
|
||||
poolTenants: DatabaseTenants;
|
||||
};
|
||||
export default _default;
|
||||
@@ -0,0 +1,148 @@
|
||||
// poolSingleton.mjs
|
||||
// Conexión Singleton a base de datos (pg/Pool) para CORE y TENANTS.
|
||||
// Cambios mínimos respecto a tu versión original.
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
// Utilidad mínima para booleans
|
||||
const isTrue = (v) => String(v).toLowerCase() === 'true';
|
||||
|
||||
// --------------------- CORE ---------------------
|
||||
class DatabaseCore {
|
||||
static instance = null;
|
||||
|
||||
constructor() {
|
||||
if (DatabaseCore.instance) {
|
||||
return DatabaseCore.instance; // <-- corrección: antes devolvía Database.instance
|
||||
}
|
||||
|
||||
const host = process.env.CORE_DB_HOST;
|
||||
const user = process.env.CORE_DB_USER;
|
||||
const password = process.env.CORE_DB_PASS;
|
||||
const database = process.env.CORE_DB_NAME;
|
||||
const port = process.env.CORE_DB_PORT;
|
||||
const ssl =
|
||||
isTrue(process.env.CORE_PGSSL ?? process.env.PGSSL)
|
||||
? { rejectUnauthorized: false }
|
||||
: undefined;
|
||||
|
||||
const config = {
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
port: port ? Number(port) : undefined,
|
||||
ssl,
|
||||
};
|
||||
|
||||
this.host = host;
|
||||
this.dbName = database;
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseCore.instance = this;
|
||||
}
|
||||
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql, params);
|
||||
}
|
||||
|
||||
async connect() { // idempotente a nivel de pool; retorna un client
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async getClient() { // alias simple, conserva tu API
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() { // cierra TODO el pool (uso excepcional)
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------- TENANTS ---------------------
|
||||
class DatabaseTenants {
|
||||
static instance = null;
|
||||
|
||||
constructor() {
|
||||
if (DatabaseTenants.instance) {
|
||||
return DatabaseTenants.instance; // <-- corrección: antes devolvía Database.instance
|
||||
}
|
||||
|
||||
const host = process.env.TENANTS_DB_HOST;
|
||||
const user = process.env.TENANTS_DB_USER;
|
||||
const password = process.env.TENANTS_DB_PASS;
|
||||
const database = process.env.TENANTS_DB_NAME;
|
||||
const port = process.env.TENANTS_DB_PORT;
|
||||
const ssl =
|
||||
isTrue(process.env.TENANTS_PGSSL ?? process.env.PGSSL)
|
||||
? { rejectUnauthorized: false }
|
||||
: undefined;
|
||||
|
||||
const config = {
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
port: port ? Number(port) : undefined,
|
||||
ssl,
|
||||
};
|
||||
|
||||
this.host = host;
|
||||
this.dbName = database;
|
||||
this.connection = new Pool(config);
|
||||
|
||||
DatabaseTenants.instance = this;
|
||||
}
|
||||
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql, params);
|
||||
}
|
||||
|
||||
async connect() { // idempotente a nivel de pool; retorna un client
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async getClient() { // alias simple, conserva tu API
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() { // cierra TODO el pool (uso excepcional)
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Instancias únicas por el cache de módulos de Node/ESM + guardas estáticas
|
||||
const poolCore = new DatabaseCore();
|
||||
const poolTenants = new DatabaseTenants();
|
||||
|
||||
// --------------------- Healthchecks aquí dentro ---------------------
|
||||
async function verificarConexionCore() {
|
||||
try {
|
||||
console.log(`[ PG ] Comprobando accesibilidad a la db ${poolCore.dbName} del host ${poolCore.host} ...`);
|
||||
const client = await poolCore.getClient();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`[ PG ] Conexión con ${poolCore.dbName} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[ PG ] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[ PG ] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
async function verificarConexionTenants() {
|
||||
try {
|
||||
console.log(`[ PG ] Comprobando accesibilidad a la db ${poolTenants.dbName} del host ${poolTenants.host} ...`);
|
||||
const client = await poolTenants.getClient();
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`[ PG ] Conexión con ${poolTenants.dbName} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('[ PG ] Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('[ PG ] Revisar credenciales, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
// Exports (mantengo tu patrón)
|
||||
export default { poolCore, poolTenants, verificarConexionCore, verificarConexionTenants };
|
||||
export { poolCore, poolTenants, verificarConexionCore, verificarConexionTenants };
|
||||
// export { DatabaseCore, DatabaseTenants }; // si lo necesitás para tests
|
||||
@@ -0,0 +1,14 @@
|
||||
// @suitecoffee/middlewares/datosGlobales.mjs
|
||||
// packages/core/middlewares/datosGlobales.mjs
|
||||
|
||||
import { Router } from 'express';
|
||||
export const datosGlobales = Router();
|
||||
|
||||
datosGlobales.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = 'SuiteCoffee';
|
||||
res.locals.pageId = '';
|
||||
next();
|
||||
});
|
||||
|
||||
export default datosGlobales; // opcional, pero útil si alguien quiere import default
|
||||
@@ -0,0 +1,7 @@
|
||||
// @suitecoffee/middlewares/src/index.mjs
|
||||
// Punto de entrada general del paquete de middlewares.
|
||||
|
||||
export * from './requireAuth.mjs';
|
||||
export * from './datosGlobales.mjs';
|
||||
export * from './tenantContext.mjs';
|
||||
export * from './resolveTenantFromCore.mjs';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@suitecoffee/middlewares",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": ".index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.mjs",
|
||||
"default": "./index.mjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
".index.mjs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// packages/core/middlewares/src/requireAuth.mjs
|
||||
// @suitecoffee/middlewares/src/requireAuth.mjs
|
||||
|
||||
/**
|
||||
* requireAuth
|
||||
* Verifica que exista una sesión válida en req.session.user (con `sub`).
|
||||
* - Si hay sesión, llama a next().
|
||||
* - Si no hay sesión:
|
||||
* - Si se define `redirectTo`, redirige (302) cuando el cliente acepta HTML.
|
||||
* - En caso contrario, responde 401 con { error: 'unauthenticated' }.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {string|null} [options.redirectTo=null] Ruta a la que redirigir si no hay sesión (p.ej. '/auth/login')
|
||||
* @param {(req: import('express').Request) => any} [options.getSessionUser] Cómo leer el usuario de la sesión
|
||||
* @returns {import('express').RequestHandler}
|
||||
*
|
||||
* Uso típico:
|
||||
* import { requireAuth } from '@suitecoffee/middlewares';
|
||||
* app.get('/me', requireAuth(), (req,res)=> res.json({ user: req.session.user }));
|
||||
* app.get('/dashboard', requireAuth({ redirectTo: '/auth/login' }), handler);
|
||||
*/
|
||||
export function requireAuth(options = {}) {
|
||||
const {
|
||||
redirectTo = null,
|
||||
getSessionUser = (req) => req?.session?.user,
|
||||
} = options;
|
||||
|
||||
return function requireAuthMiddleware(req, res, next) {
|
||||
const user = getSessionUser(req);
|
||||
|
||||
if (user && user.sub) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Si el cliente acepta HTML y tenemos redirectTo, redirigimos (útil para front web)
|
||||
if (redirectTo && req.accepts('html')) {
|
||||
return res.redirect(302, redirectTo);
|
||||
}
|
||||
|
||||
// Fallback JSON
|
||||
return res.status(401).json({ error: 'unauthenticated' });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// packages/core/middlewares/resolveTenantFromCore.mjs
|
||||
import { poolCore, poolTenants } from '@suitecoffee/db';
|
||||
|
||||
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Verifica si existe el esquema en la DB de tenants.
|
||||
* No requiere setear search_path.
|
||||
*/
|
||||
async function schemaExists(schemaName) {
|
||||
if (!schemaName) return false;
|
||||
const q = `
|
||||
SELECT 1
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
const { rowCount } = await poolTenants.query(q, [schemaName]);
|
||||
return rowCount === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve { id, schema } o null.
|
||||
* Reglas:
|
||||
* 1) Si el usuario tiene default_tenant => usarlo (y validar estado y existencia del schema).
|
||||
* 2) Si no, buscar membresías:
|
||||
* - si hay exactamente 1 => usarla (validando schema).
|
||||
* - si hay 0 o >1 => devolver null (forzar selección explícita).
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {any} sess (req.session)
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.debug=false]
|
||||
* @param {Console} [opts.logger=console]
|
||||
* @param {string[]} [opts.acceptStates=['ready']] // estados de sc_tenants aceptados
|
||||
* @returns {Promise<{id:string, schema:string} | null>}
|
||||
*/
|
||||
export async function resolveTenantFromCore(req, sess, opts = {}) {
|
||||
const {
|
||||
debug = false,
|
||||
logger = console,
|
||||
acceptStates = ['ready'],
|
||||
} = opts;
|
||||
|
||||
const log = (msg, obj) => {
|
||||
if (debug) logger.debug?.(`[resolveTenantFromCore] ${msg}`, obj ?? '');
|
||||
};
|
||||
|
||||
const sub = sess?.user?.sub;
|
||||
if (!sub) {
|
||||
log('no-sub-in-session');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) sc_users: obtener user_id y default_tenant
|
||||
const uSql = `
|
||||
SELECT user_id, default_tenant
|
||||
FROM sc_users
|
||||
WHERE sub = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
const ures = await poolCore.query(uSql, [sub]);
|
||||
if (ures.rowCount === 0) {
|
||||
log('user-not-found', { sub });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { user_id, default_tenant } = ures.rows[0] ?? {};
|
||||
|
||||
// Helper para validar fila de tenant y existencia de schema
|
||||
const validateTenantRow = async (row) => {
|
||||
if (!row) return null;
|
||||
const { tenant_id, schema_name, state } = row;
|
||||
if (!UUID_RX.test(String(tenant_id))) return null;
|
||||
if (!schema_name) return null;
|
||||
if (acceptStates.length && !acceptStates.includes(String(state))) return null;
|
||||
|
||||
// Comprobar que el schema exista realmente en la DB de tenants
|
||||
const exists = await schemaExists(schema_name);
|
||||
if (!exists) {
|
||||
log('schema-missing-in-tenants-db', { schema_name });
|
||||
return null;
|
||||
}
|
||||
return { id: String(tenant_id), schema: String(schema_name) };
|
||||
};
|
||||
|
||||
// 2) Si hay default_tenant, cargar su schema y validar
|
||||
if (default_tenant) {
|
||||
const tSql = `
|
||||
SELECT tenant_id, schema_name, state
|
||||
FROM sc_tenants
|
||||
WHERE tenant_id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
const tres = await poolCore.query(tSql, [default_tenant]);
|
||||
if (tres.rowCount === 1) {
|
||||
const ok = await validateTenantRow(tres.rows[0]);
|
||||
if (ok) {
|
||||
sess.tenant = ok;
|
||||
log('resolved-from-default_tenant', ok);
|
||||
return ok;
|
||||
}
|
||||
// default_tenant roto → seguimos a membresías
|
||||
log('default_tenant-invalid', { default_tenant });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Sin default_tenant válido: ver membresías (aceptando sólo tenants en estados permitidos)
|
||||
const mSql = `
|
||||
SELECT m.tenant_id, t.schema_name, t.state, t.created_at, m.role
|
||||
FROM sc_memberships m
|
||||
JOIN sc_tenants t USING (tenant_id)
|
||||
WHERE m.user_id = $1
|
||||
${acceptStates.length ? `AND t.state = ANY($2)` : ''}
|
||||
ORDER BY (m.role = 'owner') DESC, t.created_at ASC
|
||||
LIMIT 2
|
||||
`;
|
||||
const mParams = acceptStates.length ? [user_id, acceptStates] : [user_id];
|
||||
const mres = await poolCore.query(mSql, mParams);
|
||||
|
||||
if (mres.rowCount === 1) {
|
||||
const ok = await validateTenantRow(mres.rows[0]);
|
||||
if (ok) {
|
||||
sess.tenant = ok;
|
||||
log('resolved-from-single-membership', ok);
|
||||
return ok;
|
||||
}
|
||||
log('single-membership-invalid-row', mres.rows[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 0 o >1 membresías → el usuario debe elegir explícitamente
|
||||
log('ambiguous-or-no-memberships', { count: mres.rowCount });
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.error?.('[resolveTenantFromCore] error', { message: err?.message });
|
||||
return null; // preferimos no romper el request; el middleware decidirá
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// packages/core/middlewares/src/tenantContext.mjs
|
||||
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; // schema seguro
|
||||
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function redact(obj) {
|
||||
// Evita loggear datos sensibles; muestra sólo lo útil para diagnóstico
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
const out = {};
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (['token', 'access_token', 'id_token', 'refresh_token'].includes(k)) {
|
||||
out[k] = '[redacted]';
|
||||
} else if (k === 'sub' || k === 'email' || k === 'name') {
|
||||
out[k] = obj[k];
|
||||
} else if (k === 'tenant') {
|
||||
const t = obj[k] || {};
|
||||
out[k] = { id: t.id ?? null, schema: t.schema ?? null };
|
||||
} else if (k === 'user') {
|
||||
const u = obj[k] || {};
|
||||
out[k] = {
|
||||
sub: u.sub ?? null,
|
||||
email: u.email ?? null,
|
||||
default_tenant: u.default_tenant ?? u.defaultTenant ?? null,
|
||||
memberships: Array.isArray(u.memberships) ? `[${u.memberships.length}]` : null,
|
||||
};
|
||||
} else {
|
||||
// no inundar el log; deja constancia de que existe
|
||||
out[k] = '[present]';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tenantContext(opts = {}) {
|
||||
const {
|
||||
requireUser = true,
|
||||
debug = false,
|
||||
log = console, // podés inyectar tu logger
|
||||
autoDeriveFromDefault = true,
|
||||
// callback opcional para buscar tenant (p.ej., en CORE) si no está en sesión
|
||||
// Debe devolver { id: uuid, schema: string } o null
|
||||
resolveTenant = null,
|
||||
schemaPrefixes = [
|
||||
process.env.TENANT_SCHEMA_PREFIX || 'empresa_',
|
||||
].filter(Boolean),
|
||||
} = opts;
|
||||
|
||||
const diag = (msg, data) => {
|
||||
if (!debug) return;
|
||||
try { log.debug?.(`[tenantContext] ${msg}`, data !== undefined ? redact(data) : ''); }
|
||||
catch { /* noop */ }
|
||||
};
|
||||
const setDiagHeader = (res, kv) => {
|
||||
if (!debug) return;
|
||||
const cur = res.getHeader('X-Tenant-Diag');
|
||||
const base = typeof cur === 'string' ? String(cur) + '; ' : '';
|
||||
res.setHeader('X-Tenant-Diag', base + kv);
|
||||
};
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
diag('incoming', { sid: req.sessionID, headers: { accept: req.headers.accept } });
|
||||
|
||||
const sess = req.session;
|
||||
if (!sess) {
|
||||
setDiagHeader(res, 'no-session');
|
||||
return res.status(401).json({ error: 'unauthenticated' });
|
||||
}
|
||||
diag('session.present', { keys: Object.keys(sess) });
|
||||
|
||||
if (requireUser && !sess.user?.sub) {
|
||||
diag('user.missing', { session: sess });
|
||||
setDiagHeader(res, 'no-user');
|
||||
return res.status(401).json({ error: 'unauthenticated' });
|
||||
}
|
||||
if (requireUser) diag('user.ok', sess.user);
|
||||
|
||||
// 1) Leer tenant desde sesión
|
||||
let t = sess.tenant ?? null;
|
||||
diag('session.tenant', t);
|
||||
|
||||
// 2) Derivar automáticamente si falta
|
||||
if ((!t?.id || !t?.schema) && autoDeriveFromDefault) {
|
||||
const fallbackId =
|
||||
sess.user?.tenant?.id ||
|
||||
sess.user?.default_tenant ||
|
||||
sess.user?.defaultTenant ||
|
||||
null;
|
||||
|
||||
if (fallbackId && UUID_RX.test(String(fallbackId))) {
|
||||
const prefix = String(schemaPrefixes[0] || 'empresa_');
|
||||
const schema = `${prefix}${String(fallbackId).replace(/-/g, '').toLowerCase()}`;
|
||||
t = { id: String(fallbackId), schema };
|
||||
sess.tenant = t; // persistir para siguientes requests
|
||||
diag('derived.fromDefault', t);
|
||||
setDiagHeader(res, 'derived-default');
|
||||
} else {
|
||||
diag('derived.fromDefault.skipped', { fallbackId });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Resolver con callback si aún falta
|
||||
if ((!t?.id || !t?.schema) && typeof resolveTenant === 'function') {
|
||||
try {
|
||||
t = await resolveTenant(req, sess);
|
||||
if (t) {
|
||||
sess.tenant = t;
|
||||
diag('derived.fromResolver', t);
|
||||
setDiagHeader(res, 'derived-resolver');
|
||||
} else {
|
||||
diag('resolver.returned-null');
|
||||
}
|
||||
} catch (e) {
|
||||
diag('resolver.error', { message: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Validaciones
|
||||
if (!t?.id || !t?.schema) {
|
||||
diag('missing-tenant.final');
|
||||
setDiagHeader(res, 'missing-tenant');
|
||||
return res.status(401).json({ error: 'Sesión inválida o tenant no seleccionado' });
|
||||
}
|
||||
if (!UUID_RX.test(String(t.id))) {
|
||||
diag('invalid-tenant-id', t);
|
||||
setDiagHeader(res, 'bad-tenant-id');
|
||||
return res.status(400).json({ error: 'TenantID inválido' });
|
||||
}
|
||||
if (!VALID_IDENT.test(t.schema)) {
|
||||
diag('invalid-schema', t);
|
||||
setDiagHeader(res, 'bad-schema');
|
||||
return res.status(400).json({ error: 'Schema inválido' });
|
||||
}
|
||||
const okPrefix = schemaPrefixes.some(p =>
|
||||
t.schema.toLowerCase().startsWith(String(p).toLowerCase()),
|
||||
);
|
||||
if (!okPrefix) {
|
||||
diag('schema-prefix.rejected', { schema: t.schema, schemaPrefixes });
|
||||
setDiagHeader(res, 'schema-prefix-rejected');
|
||||
return res.status(400).json({ error: 'Schema no permitido' });
|
||||
}
|
||||
|
||||
// 5) OK
|
||||
req.tenant = { id: String(t.id), schema: String(t.schema) };
|
||||
res.locals.tenant = req.tenant;
|
||||
setDiagHeader(res, `ok schema=${req.tenant.schema}`);
|
||||
diag('attach.req.tenant', req.tenant);
|
||||
|
||||
return next();
|
||||
} catch (err) {
|
||||
diag('exception', { message: err?.message });
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@suitecoffee/redis",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./redisSingleton.mjs",
|
||||
"types": "./redisSingleton.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./redisSingleton.d.ts",
|
||||
"import": "./redisSingleton.mjs",
|
||||
"default": "./redisSingleton.mjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"files": [
|
||||
"redisSingleton.mjs",
|
||||
"redisSingleton.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// redisSingleton.mjs
|
||||
// Conexión Singleton a Redis para Authentik (AK)
|
||||
|
||||
import { createClient } from 'redis';
|
||||
|
||||
class RedisAuthentik {
|
||||
static instance = null;
|
||||
|
||||
constructor() {
|
||||
if (RedisAuthentik.instance) {
|
||||
return RedisAuthentik.instance;
|
||||
}
|
||||
|
||||
const url = process.env.AK_REDIS_URL;
|
||||
if (!url) {
|
||||
throw new Error('Falta AK_REDIS_URL Ej: redis://:pass@host:6379/0');
|
||||
}
|
||||
if (!/^redis(s)?:\/\//i.test(url)) {
|
||||
throw new Error('AK_REDIS_URL inválida: debe comenzar con "redis://" o "rediss://".');
|
||||
}
|
||||
|
||||
this.url = url;
|
||||
this.client = createClient({
|
||||
url: this.url,
|
||||
socket: { connectTimeout: 5000 },
|
||||
});
|
||||
|
||||
this.client.on('connect', () => console.log(`[REDIS AK] Conectando a ${this.url}`));
|
||||
this.client.on('ready', () => console.log('[REDIS AK] Conexión lista.'));
|
||||
this.client.on('end', () => console.warn('[REDIS AK] Conexión cerrada.'));
|
||||
this.client.on('reconnecting', () => console.warn('[REDIS AK] Reintentando conexión...'));
|
||||
this.client.on('error', (err) => console.error('[REDIS AK] Error:', err?.message || err));
|
||||
|
||||
this._connectingPromise = null;
|
||||
RedisAuthentik.instance = this;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.client.isOpen) return this.client;
|
||||
if (this._connectingPromise) return this._connectingPromise;
|
||||
|
||||
this._connectingPromise = this.client.connect()
|
||||
.then(() => this.client)
|
||||
.catch((err) => {
|
||||
this._connectingPromise = null;
|
||||
console.error('[REDIS AK] Falló la conexión inicial:', err?.message || err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
return this._connectingPromise;
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async release() {
|
||||
try {
|
||||
if (this.client?.isOpen) await this.client.quit();
|
||||
} catch (e) {
|
||||
console.warn('[REDIS AK] Error al cerrar:', e?.message || e);
|
||||
} finally {
|
||||
this._connectingPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instancia única
|
||||
const redisAuthentik = new RedisAuthentik();
|
||||
|
||||
// --------------------- Healthcheck ---------------------
|
||||
async function verificarConexionRedisAuthentik() {
|
||||
try {
|
||||
console.log(`[REDIS AK] Comprobando accesibilidad a Redis en ${redisAuthentik.url} ...`);
|
||||
await redisAuthentik.connect();
|
||||
const client = redisAuthentik.getClient();
|
||||
|
||||
const pong = await client.ping();
|
||||
const timeArr = await client.sendCommand(['TIME']);
|
||||
const serverDate = new Date(Number(timeArr?.[0] || 0) * 1000);
|
||||
|
||||
await client.set('hc:authentik', String(Date.now()), { EX: 10 });
|
||||
|
||||
console.log(`[REDIS AK] Conexión OK. PING=${pong}. Hora Redis:`, serverDate.toISOString());
|
||||
} catch (error) {
|
||||
console.error('[REDIS AK] Error al conectar:', error?.message || error);
|
||||
console.error('[REDIS AK] Revisar AK_REDIS_URL, credenciales, red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
// Export al estilo de poolSingleton.mjs
|
||||
export default { redisAuthentik, verificarConexionRedisAuthentik };
|
||||
export { redisAuthentik, verificarConexionRedisAuthentik };
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@suitecoffee/scripts",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": ".src/index.mjs",
|
||||
"types": ".src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.d.ts",
|
||||
"import": "./src/index.mjs",
|
||||
"default": "./src/index.mjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"srcindex.mjs",
|
||||
"srcindex.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// @suitecoffee/scripts/src/index.mjs
|
||||
// Punto de entrada general del paquete de utilidades.
|
||||
|
||||
export * from './utils/env.mjs';
|
||||
@@ -0,0 +1,24 @@
|
||||
// @suitecoffee/scripts/src/utils/env.mjs
|
||||
|
||||
/**
|
||||
* checkRequiredEnvVars
|
||||
* Verifica que todas las variables de entorno requeridas existan en process.env.
|
||||
* Muestra advertencias si alguna falta.
|
||||
*
|
||||
* @param {...string} requiredKeys - Lista de nombres de variables esperadas
|
||||
*/
|
||||
|
||||
|
||||
export function checkRequiredEnvVars(...requiredKeys) {
|
||||
const missingKeys = requiredKeys.filter((key) => !process.env[key]);
|
||||
|
||||
if (missingKeys.length > 0) {
|
||||
console.warn(
|
||||
`[ ENV ] No se encontraron las siguientes variables de entorno:\n\n` +
|
||||
missingKeys.map((k) => `-> ${k}`).join('\n') +
|
||||
`\n`
|
||||
);
|
||||
} else {
|
||||
console.log(`[ ENV ] Todas las variables de entorno requeridas están definidas.`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user