feat(database): refactor CLI, add column whitelist, and SSL config

- Add `ZEN_DB_SSL_DISABLED` env variable to allow disabling SSL for database connections
- Refactor database CLI to split init logic into `initFeatures` and `initModules` for modular table initialization, with graceful fallback when modules are absent
- Extract `printHelp` and `askConfirmation` helpers for cleaner CLI structure
- Ensure `closePool` is called on both success and error paths in CLI
- Add `filterAllowedColumns` utility in `crud.js` to enforce column whitelists, preventing mass-assignment of privileged fields (e.g. `role`, `email_verified`)
- Update drop command description from "auth tables" to "all tables"
This commit is contained in:
2026-04-13 16:35:23 -04:00
parent 6521179e10
commit a3921a0b98
11 changed files with 691 additions and 295 deletions
+1
View File
@@ -13,6 +13,7 @@ ZEN_SUPPORT_EMAIL=support@exemple.com
# DATABASE # DATABASE
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
ZEN_DB_SSL_DISABLED=false
# STORAGE (Cloudflare R2 for now) # STORAGE (Cloudflare R2 for now)
ZEN_STORAGE_BUCKET=my-bucket-name ZEN_STORAGE_BUCKET=my-bucket-name
+59 -43
View File
@@ -16,15 +16,12 @@ dotenv.config({ path: resolve(process.cwd(), '.env.local') });
// The CLI always runs locally, so default to development to use ZEN_DATABASE_URL_DEV if set // The CLI always runs locally, so default to development to use ZEN_DATABASE_URL_DEV if set
process.env.NODE_ENV = process.env.NODE_ENV || 'development'; process.env.NODE_ENV = process.env.NODE_ENV || 'development';
import { initDatabase, dropAuthTables, testConnection, closePool } from '../core/database/index.js'; import { testConnection, closePool } from '../core/database/index.js';
import readline from 'readline'; import readline from 'readline';
import { step, done, warn, fail } from '../shared/lib/logger.js'; import { step, done, warn, fail } from '../shared/lib/logger.js';
async function runCLI() { function printHelp() {
const command = process.argv[2]; console.log(`
if (!command) {
console.log(`
Zen Database CLI Zen Database CLI
Usage: Usage:
@@ -33,22 +30,59 @@ Usage:
Commands: Commands:
init Initialize database (create all required tables) init Initialize database (create all required tables)
test Test database connection test Test database connection
drop Drop all authentication tables (DANGER!) drop Drop all tables (DANGER!)
help Show this help message help Show this help message
Example: Example:
npx zen-db init npx zen-db init
`); `);
}
/**
* Prompt the user for a confirmation answer.
* @param {string} question
* @returns {Promise<string>} The trimmed, lowercased answer
*/
function askConfirmation(question) {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase());
});
});
}
async function runCLI() {
const command = process.argv[2];
if (!command) {
printHelp();
process.exit(0); process.exit(0);
} }
try { try {
switch (command) { switch (command) {
case 'init': case 'init': {
step('Initializing database...'); step('Initializing database...');
const result = await initDatabase();
done(`Created ${result.created.length} tables, skipped ${result.skipped.length} existing tables`); const { initFeatures } = await import('../features/init.js');
const featuresResult = await initFeatures();
// Module tables are initialized per-module, if present
let modulesResult = { created: [], skipped: [] };
try {
const { initModules } = await import('../modules/init.js');
modulesResult = await initModules();
} catch {
// Modules may not be present in all project setups — silently skip
}
const totalCreated = featuresResult.created.length + modulesResult.created.length;
const totalSkipped = featuresResult.skipped.length + modulesResult.skipped.length;
done(`DB ready — ${totalCreated} tables created, ${totalSkipped} skipped`);
break; break;
}
case 'test': case 'test':
step('Testing database connection...'); step('Testing database connection...');
@@ -61,40 +95,22 @@ Example:
} }
break; break;
case 'drop': case 'drop': {
warn('This will delete all authentication tables!'); warn('This will delete all tables!');
process.stdout.write(' Type "yes" to confirm or Ctrl+C to cancel...\n'); process.stdout.write(' Type "yes" to confirm or Ctrl+C to cancel...\n');
const answer = await askConfirmation('Confirm (yes/no): ');
const rl = readline.createInterface({ if (answer === 'yes') {
input: process.stdin, const { dropFeatures } = await import('../features/init.js');
output: process.stdout await dropFeatures();
}); done('Tables dropped successfully');
} else {
rl.question('Confirm (yes/no): ', async (answer) => { warn('Operation cancelled');
if (answer.toLowerCase() === 'yes') { }
await dropAuthTables(); break;
done('Tables dropped successfully'); }
} else {
warn('Operation cancelled');
}
rl.close();
process.exit(0);
});
return; // Don't close the process yet
case 'help': case 'help':
console.log(` printHelp();
Zen Database CLI
Commands:
init Initialize database (create all required tables)
test Test database connection
drop Drop all authentication tables (DANGER!)
help Show this help message
Usage:
npx zen-db <command>
`);
break; break;
default: default:
@@ -103,12 +119,12 @@ Usage:
process.exit(1); process.exit(1);
} }
// Close the database connection pool
await closePool(); await closePool();
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
fail(`Error: ${error.message}`); fail(`Error: ${error.message}`);
await closePool();
process.exit(1); process.exit(1);
} }
} }
+292
View File
@@ -0,0 +1,292 @@
# Database
Ce répertoire est le **module de base de données**. Il fournit une couche d'accès PostgreSQL générique : connexion, requêtes paramétrées, transactions et helpers CRUD. Il ne connaît aucune feature spécifique — les features l'importent pour leurs propres besoins.
---
## Structure
```
src/core/database/
├── index.js Exports publics (query, transaction, create, find, update…)
├── db.js Pool de connexion, DatabaseError, fonctions de requête bas niveau
└── crud.js Helpers CRUD (create, find, update, delete, count, exists…)
```
---
## Variables d'environnement
| Variable | Rôle |
|----------|------|
| `ZEN_DATABASE_URL` | URL de connexion PostgreSQL (tous environnements) |
| `ZEN_DATABASE_URL_DEV` | URL de connexion en développement (prioritaire sur `ZEN_DATABASE_URL` si `NODE_ENV=development`) |
| `ZEN_DB_SSL_DISABLED` | Mettre à `true` pour désactiver TLS entièrement (loopback local uniquement) |
### Politique TLS
| Environnement | Comportement |
|---------------|-------------|
| `production` | TLS activé, vérification du certificat serveur (`rejectUnauthorized: true`) |
| Autres (`development`, `test`…) | TLS activé, vérification désactivée (accepte les certificats auto-signés) |
| `ZEN_DB_SSL_DISABLED=true` | TLS désactivé (usage local uniquement) |
---
## DatabaseError
Toutes les opérations base de données lèvent une `DatabaseError` en cas d'échec. Elle expose uniquement un message générique et le code d'erreur PostgreSQL (SQLSTATE), sans jamais retourner de nom de table, de contrainte ou de fragment SQL.
```js
import { DatabaseError } from '@zen/core/database';
try {
await create('users', { email });
} catch (error) {
if (error instanceof DatabaseError && error.code === '23505') {
// Violation de contrainte unique (ex: email déjà utilisé)
}
}
```
Codes SQLSTATE courants :
| Code | Signification |
|------|--------------|
| `23505` | Violation de contrainte unique (`UNIQUE`) |
| `23503` | Violation de contrainte de clé étrangère (`FOREIGN KEY`) |
| `23502` | Violation de contrainte `NOT NULL` |
---
## Fonctions de requête bas niveau (`db.js`)
### `query(sql, params?)`
Exécute une requête SQL paramétrée et retourne l'objet résultat complet `pg`.
```js
const result = await query('SELECT * FROM users WHERE id = $1', [userId]);
// result.rows, result.rowCount…
```
### `queryOne(sql, params?)`
Retourne la première ligne ou `null`.
```js
const user = await queryOne('SELECT * FROM users WHERE email = $1', [email]);
```
### `queryAll(sql, params?)`
Retourne toutes les lignes sous forme de tableau.
```js
const posts = await queryAll('SELECT * FROM posts WHERE published = true');
```
### `transaction(callback)`
Exécute plusieurs requêtes dans une transaction atomique. Rollback automatique en cas d'erreur.
```js
const result = await transaction(async (client) => {
const post = await client.query(
'INSERT INTO posts (title) VALUES ($1) RETURNING *',
['Mon article']
);
await client.query(
'INSERT INTO post_tags (post_id, tag) VALUES ($1, $2)',
[post.rows[0].id, 'actu']
);
return post.rows[0];
});
```
### `testConnection()`
Vérifie que la connexion à la base est établie. Retourne `true` ou `false`.
### `tableExists(tableName)`
Vérifie si une table existe dans le schéma `public`. Retourne `true` ou `false`.
```js
if (!(await tableExists('users'))) {
// table absente — migration non appliquée
}
```
---
## Helpers CRUD (`crud.js`)
Toutes les fonctions CRUD construisent du SQL **entièrement paramétré**. Les noms de tables et de colonnes passent par `safeIdentifier()` — injection SQL impossible.
### `create(tableName, data, options?)`
Insère un enregistrement et retourne la ligne créée (via `RETURNING *`).
```js
const user = await create('users', { email, name, role: 'user' });
```
Option `allowedColumns` (recommandée) : liste blanche explicite des colonnes autorisées. Toute clé absente de cette liste lève immédiatement une erreur.
```js
const user = await create('users', body, {
allowedColumns: ['email', 'name'],
// 'role' absent → une tentative de mass-assignment lèvera une erreur
});
```
### `findById(tableName, id, idColumn?)`
Retourne l'enregistrement correspondant à l'identifiant, ou `null`.
```js
const post = await findById('posts', 42);
const user = await findById('users', slug, 'slug');
```
### `find(tableName, conditions?, options?)`
Retourne un tableau d'enregistrements correspondant aux conditions.
```js
const published = await find('posts', { published: true }, {
orderBy: 'created_at DESC',
limit: 10,
offset: 0,
});
```
Options disponibles :
| Option | Type | Contrainte |
|--------|------|-----------|
| `orderBy` | `string` | `"colonne"` ou `"colonne ASC\|DESC"` |
| `limit` | `number` | Entier entre 1 et 10 000 |
| `offset` | `number` | Entier ≥ 0 |
### `findOne(tableName, conditions)`
Retourne le premier enregistrement correspondant, ou `null`.
```js
const user = await findOne('users', { email });
```
### `updateById(tableName, id, data, idColumn?, options?)`
Met à jour un enregistrement par identifiant et retourne la ligne mise à jour, ou `null` si introuvable.
```js
const updated = await updateById('users', userId, { name: 'Alice' });
// Avec whitelist
const updated = await updateById('users', userId, body, 'id', {
allowedColumns: ['name', 'avatar_url'],
});
```
### `update(tableName, conditions, data, options?)`
Met à jour tous les enregistrements correspondant aux conditions. **Au moins une condition est obligatoire** — une mise à jour sans condition lève immédiatement une erreur.
```js
const rows = await update('posts', { author_id: userId }, { published: false });
```
### `deleteById(tableName, id, idColumn?)`
Supprime un enregistrement par identifiant. Retourne `true` si supprimé, `false` sinon.
```js
const deleted = await deleteById('posts', postId);
```
### `deleteWhere(tableName, conditions)`
Supprime tous les enregistrements correspondant aux conditions. **Au moins une condition est obligatoire** — une suppression sans condition lève immédiatement une erreur. Retourne le nombre de lignes supprimées.
```js
const count = await deleteWhere('sessions', { user_id: userId });
```
### `count(tableName, conditions?)`
Retourne le nombre d'enregistrements correspondant aux conditions (ou le total si aucune condition).
```js
const total = await count('users');
const admins = await count('users', { role: 'admin' });
```
### `exists(tableName, conditions)`
Retourne `true` si au moins un enregistrement correspond aux conditions.
```js
const taken = await exists('users', { email });
```
---
## Utilitaires de sécurité
### `safeIdentifier(name)`
Valide et encadre de guillemets doubles un identifiant SQL (nom de table ou de colonne). Autorise uniquement `[A-Za-z_][A-Za-z0-9_]*`, longueur max 63. Lève une erreur pour tout identifiant invalide.
```js
safeIdentifier('users') // → '"users"'
safeIdentifier('my-table') // → Error: SQL identifier contains disallowed characters
```
### `safeOrderBy(orderBy)`
Valide une expression `ORDER BY` : `"colonne"` ou `"colonne ASC|DESC"`. Utilise `safeIdentifier` en interne.
```js
safeOrderBy('created_at DESC') // → '"created_at" DESC'
safeOrderBy('1=1; DROP TABLE') // → Error
```
### `filterAllowedColumns(data, allowedColumns?)`
Filtre un objet de données selon une liste blanche de colonnes. Sans liste blanche, retourne l'objet tel quel. Avec liste blanche, toute clé absente lève immédiatement une erreur.
```js
filterAllowedColumns({ name: 'Alice', role: 'admin' }, ['name'])
// → Error: Column "role" is not in the permitted columns list
```
---
## Usage depuis une feature
```js
// src/features/myfeature/db.js
import { create, find, updateById, deleteById, exists } from '../../core/database/index.js';
export async function createItem(data) {
return create('items', data, { allowedColumns: ['title', 'content', 'author_id'] });
}
export async function getPublishedItems() {
return find('items', { published: true }, { orderBy: 'created_at DESC', limit: 50 });
}
```
## Usage depuis un module
```js
// src/modules/mymodule/crud.js
import { create, findById } from '@zen/core/database';
export async function createEntry(data) {
return create('mymodule_entries', data, { allowedColumns: ['title', 'slug'] });
}
```
+86 -35
View File
@@ -5,6 +5,30 @@
import { query, queryOne, queryAll } from './db.js'; import { query, queryOne, queryAll } from './db.js';
/**
* Filter a data object to only the columns present in allowedColumns.
* If allowedColumns is omitted or empty the original object is returned unchanged
* (backward-compatible default). When a whitelist IS provided, any key not in the
* list causes an immediate throw — mass-assignment of privileged columns (e.g.
* role, email_verified) is therefore impossible when callers supply a whitelist.
* @param {Object} data - Raw data object to filter
* @param {string[]|undefined} allowedColumns - Explicit column whitelist
* @returns {Object} Filtered data object
* @throws {Error} If data contains a column not present in allowedColumns
*/
function filterAllowedColumns(data, allowedColumns) {
if (!allowedColumns || allowedColumns.length === 0) return data;
const allowed = new Set(allowedColumns);
const filtered = {};
for (const key of Object.keys(data)) {
if (!allowed.has(key)) {
throw new Error(`Column "${key}" is not in the permitted columns list`);
}
filtered[key] = data[key];
}
return filtered;
}
/** /**
* Validate and safely double-quote a single SQL identifier (table name, column name). * Validate and safely double-quote a single SQL identifier (table name, column name).
* PostgreSQL max identifier length is 63 bytes. Permits only [A-Za-z_][A-Za-z0-9_]*. * PostgreSQL max identifier length is 63 bytes. Permits only [A-Za-z_][A-Za-z0-9_]*.
@@ -49,16 +73,39 @@ function safeOrderBy(orderBy) {
return col; return col;
} }
/**
* Build a parameterized WHERE clause from a conditions object.
* @param {Object} conditions - Column/value pairs to match
* @param {number} startIndex - First $N placeholder index (default: 1)
* @returns {{ clause: string, values: Array }} SQL fragment and bound values
*/
function buildWhere(conditions, startIndex = 1) {
const values = [];
const clauses = Object.keys(conditions).map((key, index) => {
values.push(conditions[key]);
return `${safeIdentifier(key)} = $${startIndex + index}`;
});
return { clause: clauses.join(' AND '), values };
}
/** /**
* Insert a new record into a table * Insert a new record into a table
* @param {string} tableName - Name of the table * @param {string} tableName - Name of the table
* @param {Object} data - Object with column names as keys and values to insert * @param {Object} data - Object with column names as keys and values to insert
* @param {Object} [options={}] - Options
* @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
* any key in data not present in this list throws immediately. Omit only when the
* data object is already fully trusted and caller-constructed.
* @returns {Promise<Object>} Inserted record with all fields * @returns {Promise<Object>} Inserted record with all fields
*/ */
async function create(tableName, data) { async function create(tableName, data, { allowedColumns } = {}) {
if (!data || Object.keys(data).length === 0) {
throw new Error('create() requires at least one data field');
}
const safeData = filterAllowedColumns(data, allowedColumns);
const safeTable = safeIdentifier(tableName); const safeTable = safeIdentifier(tableName);
const columns = Object.keys(data).map(safeIdentifier); const columns = Object.keys(safeData).map(safeIdentifier);
const values = Object.values(data); const values = Object.values(safeData);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', '); const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
const sql = ` const sql = `
@@ -98,11 +145,9 @@ async function find(tableName, conditions = {}, options = {}) {
// Build WHERE clause — column names are validated via safeIdentifier // Build WHERE clause — column names are validated via safeIdentifier
if (Object.keys(conditions).length > 0) { if (Object.keys(conditions).length > 0) {
const whereConditions = Object.keys(conditions).map((key, index) => { const { clause, values: whereValues } = buildWhere(conditions);
values.push(conditions[key]); values.push(...whereValues);
return `${safeIdentifier(key)} = $${index + 1}`; sql += ` WHERE ${clause}`;
});
sql += ` WHERE ${whereConditions.join(' AND ')}`;
} }
// Add ORDER BY — validated and quoted via safeOrderBy // Add ORDER BY — validated and quoted via safeOrderBy
@@ -149,14 +194,21 @@ async function findOne(tableName, conditions) {
* @param {string} tableName - Name of the table * @param {string} tableName - Name of the table
* @param {number|string} id - ID of the record * @param {number|string} id - ID of the record
* @param {Object} data - Object with column names as keys and new values * @param {Object} data - Object with column names as keys and new values
* @param {string} idColumn - Name of the ID column (default: 'id') * @param {string} [idColumn='id'] - Name of the ID column
* @param {Object} [options={}] - Options
* @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
* any key in data not in this list throws immediately.
* @returns {Promise<Object|null>} Updated record or null if not found * @returns {Promise<Object|null>} Updated record or null if not found
*/ */
async function updateById(tableName, id, data, idColumn = 'id') { async function updateById(tableName, id, data, idColumn = 'id', { allowedColumns } = {}) {
if (!data || Object.keys(data).length === 0) {
throw new Error('updateById() requires at least one data field');
}
const safeData = filterAllowedColumns(data, allowedColumns);
const safeTable = safeIdentifier(tableName); const safeTable = safeIdentifier(tableName);
const safeIdCol = safeIdentifier(idColumn); const safeIdCol = safeIdentifier(idColumn);
const columns = Object.keys(data).map(safeIdentifier); const columns = Object.keys(safeData).map(safeIdentifier);
const values = Object.values(data); const values = Object.values(safeData);
const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', '); const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
@@ -176,34 +228,37 @@ async function updateById(tableName, id, data, idColumn = 'id') {
* @param {string} tableName - Name of the table * @param {string} tableName - Name of the table
* @param {Object} conditions - Object with column names as keys and values to match * @param {Object} conditions - Object with column names as keys and values to match
* @param {Object} data - Object with column names as keys and new values * @param {Object} data - Object with column names as keys and new values
* @param {Object} [options={}] - Options
* @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
* any key in data not in this list throws immediately.
* @returns {Promise<Array>} Array of updated records * @returns {Promise<Array>} Array of updated records
*/ */
async function update(tableName, conditions, data) { async function update(tableName, conditions, data, { allowedColumns } = {}) {
// Reject unconditional updates — a missing WHERE clause would silently mutate // Reject unconditional updates — a missing WHERE clause would silently mutate
// every row in the table. Callers must always supply at least one condition. // every row in the table. Callers must always supply at least one condition.
if (!conditions || Object.keys(conditions).length === 0) { if (!conditions || Object.keys(conditions).length === 0) {
throw new Error('update() requires at least one condition to prevent full-table mutation'); throw new Error('update() requires at least one condition to prevent full-table mutation');
} }
if (!data || Object.keys(data).length === 0) {
throw new Error('update() requires at least one data field');
}
const safeData = filterAllowedColumns(data, allowedColumns);
const safeTable = safeIdentifier(tableName); const safeTable = safeIdentifier(tableName);
const dataColumns = Object.keys(data).map(safeIdentifier); const dataColumns = Object.keys(safeData).map(safeIdentifier);
const dataValues = Object.values(data); const dataValues = Object.values(safeData);
const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', '); const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', ');
let paramIndex = dataValues.length + 1; const { clause: whereClause, values: whereValues } = buildWhere(conditions, dataValues.length + 1);
const whereConditions = Object.keys(conditions).map((key) => {
dataValues.push(conditions[key]);
return `${safeIdentifier(key)} = $${paramIndex++}`;
});
const sql = ` const sql = `
UPDATE ${safeTable} UPDATE ${safeTable}
SET ${setClause} SET ${setClause}
WHERE ${whereConditions.join(' AND ')} WHERE ${whereClause}
RETURNING * RETURNING *
`; `;
const result = await query(sql, dataValues); const result = await query(sql, [...dataValues, ...whereValues]);
return result.rows; return result.rows;
} }
@@ -232,13 +287,8 @@ async function deleteWhere(tableName, conditions) {
if (!conditions || Object.keys(conditions).length === 0) { if (!conditions || Object.keys(conditions).length === 0) {
throw new Error('deleteWhere() requires at least one condition to prevent full-table deletion'); throw new Error('deleteWhere() requires at least one condition to prevent full-table deletion');
} }
const values = []; const { clause, values } = buildWhere(conditions);
const whereConditions = Object.keys(conditions).map((key, index) => { const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${clause} RETURNING *`;
values.push(conditions[key]);
return `${safeIdentifier(key)} = $${index + 1}`;
});
const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${whereConditions.join(' AND ')} RETURNING *`;
const result = await query(sql, values); const result = await query(sql, values);
return result.rowCount; return result.rowCount;
} }
@@ -251,14 +301,12 @@ async function deleteWhere(tableName, conditions) {
*/ */
async function count(tableName, conditions = {}) { async function count(tableName, conditions = {}) {
let sql = `SELECT COUNT(*) as count FROM ${safeIdentifier(tableName)}`; let sql = `SELECT COUNT(*) as count FROM ${safeIdentifier(tableName)}`;
const values = []; let values = [];
if (Object.keys(conditions).length > 0) { if (Object.keys(conditions).length > 0) {
const whereConditions = Object.keys(conditions).map((key, index) => { const { clause, values: whereValues } = buildWhere(conditions);
values.push(conditions[key]); values = whereValues;
return `${safeIdentifier(key)} = $${index + 1}`; sql += ` WHERE ${clause}`;
});
sql += ` WHERE ${whereConditions.join(' AND ')}`;
} }
const result = await queryOne(sql, values); const result = await queryOne(sql, values);
@@ -277,6 +325,9 @@ async function exists(tableName, conditions) {
} }
export { export {
filterAllowedColumns,
safeIdentifier,
safeOrderBy,
create, create,
findById, findById,
find, find,
+51 -9
View File
@@ -7,6 +7,22 @@ import pkg from 'pg';
const { Pool } = pkg; const { Pool } = pkg;
import { fail } from '../../shared/lib/logger.js'; import { fail } from '../../shared/lib/logger.js';
/**
* Opaque error type thrown by all database operations.
* Exposes only a generic message and the pg error code (e.g. '23505') so
* callers can branch on well-known codes without receiving internal details
* such as table names, constraint names, or query fragments.
*/
export class DatabaseError extends Error {
/** @param {string} message - Safe, generic message */
/** @param {string|undefined} code - PostgreSQL error code (SQLSTATE), if any */
constructor(message, code) {
super(message);
this.name = 'DatabaseError';
if (code !== undefined) this.code = code;
}
}
let pool = null; let pool = null;
function resolveDatabaseUrl() { function resolveDatabaseUrl() {
@@ -35,9 +51,16 @@ function getPool() {
pool = new Pool({ pool = new Pool({
connectionString: databaseUrl, connectionString: databaseUrl,
// rejectUnauthorized MUST remain true in production to validate the server's // TLS policy:
// TLS certificate chain and prevent man-in-the-middle attacks. // production → full TLS with server certificate verification
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false, // all other environments → TLS encryption on, certificate verification off
// (prevents eavesdropping; allows self-signed certs in dev/ci)
// ZEN_DB_SSL_DISABLED=true → opt-out of TLS entirely (local loopback only)
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: true }
: (process.env.ZEN_DB_SSL_DISABLED === 'true'
? false
: { rejectUnauthorized: false }),
max: 20, // Maximum number of clients in the pool max: 20, // Maximum number of clients in the pool
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established
@@ -59,14 +82,14 @@ function getPool() {
* @returns {Promise<Object>} Query result * @returns {Promise<Object>} Query result
*/ */
async function query(sql, params = []) { async function query(sql, params = []) {
const client = getPool(); const dbPool = getPool(); // renamed — avoids shadowing module-level `pool`
try { try {
const result = await client.query(sql, params); const result = await dbPool.query(sql, params);
return result; return result;
} catch (error) { } catch (error) {
fail(`DB query error: ${error.message}`); fail(`DB query error: ${error.message}`);
throw error; throw new DatabaseError('A database error occurred', error.code);
} }
} }
@@ -108,7 +131,7 @@ async function transaction(callback) {
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
fail(`DB transaction error: ${error.message}`); fail(`DB transaction error: ${error.message}`);
throw error; throw new DatabaseError('A database transaction error occurred', error.code);
} finally { } finally {
client.release(); client.release();
} }
@@ -139,13 +162,32 @@ async function testConnection() {
} }
} }
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>} True if table exists, false otherwise
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
export { export {
DatabaseError,
query, query,
queryOne, queryOne,
queryAll, queryAll,
transaction, transaction,
getPool, getPool,
closePool, closePool,
testConnection testConnection,
tableExists
}; };
+6 -10
View File
@@ -5,17 +5,22 @@
// Core database functions // Core database functions
export { export {
DatabaseError,
query, query,
queryOne, queryOne,
queryAll, queryAll,
transaction, transaction,
getPool, getPool,
closePool, closePool,
testConnection testConnection,
tableExists
} from './db.js'; } from './db.js';
// CRUD helper functions // CRUD helper functions
export { export {
filterAllowedColumns,
safeIdentifier,
safeOrderBy,
create, create,
findById, findById,
find, find,
@@ -27,12 +32,3 @@ export {
count, count,
exists exists
} from './crud.js'; } from './crud.js';
// Database initialization
export {
initDatabase,
createAuthTables,
tableExists,
dropAuthTables
} from './init.js';
-185
View File
@@ -1,185 +0,0 @@
/**
* Database Initialization
* Creates required tables if they don't exist
*/
import { query } from './db.js';
import { step, done, warn, fail, info } from '../../shared/lib/logger.js';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>} True if table exists, false otherwise
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create authentication tables
* @returns {Promise<void>}
*/
async function createAuthTables() {
const tables = [
{
name: 'zen_auth_users',
sql: `
CREATE TABLE zen_auth_users (
id text NOT NULL PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE,
email_verified boolean NOT NULL DEFAULT false,
image text,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
role text DEFAULT 'user' CHECK (role IN ('admin', 'user'))
)
`
},
{
name: 'zen_auth_sessions',
sql: `
CREATE TABLE zen_auth_sessions (
id text NOT NULL PRIMARY KEY,
expires_at timestamptz NOT NULL,
token text NOT NULL UNIQUE,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz NOT NULL,
ip_address text,
user_agent text,
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE
)
`
},
{
name: 'zen_auth_accounts',
sql: `
CREATE TABLE zen_auth_accounts (
id text NOT NULL PRIMARY KEY,
account_id text NOT NULL,
provider_id text NOT NULL,
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE,
access_token text,
refresh_token text,
id_token text,
access_token_expires_at timestamptz,
refresh_token_expires_at timestamptz,
scope text,
password text,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz NOT NULL
)
`
},
{
name: 'zen_auth_verifications',
sql: `
CREATE TABLE zen_auth_verifications (
id text NOT NULL PRIMARY KEY,
identifier text NOT NULL,
value text NOT NULL,
token text NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`
}
];
const created = [];
const skipped = [];
for (const table of tables) {
const exists = await tableExists(table.name);
if (!exists) {
await query(table.sql);
created.push(table.name);
done(`Created table: ${table.name}`);
} else {
skipped.push(table.name);
info(`Table already exists: ${table.name}`);
}
}
return {
created,
skipped,
success: true
};
}
/**
* Initialize the database with all required tables
* @returns {Promise<Object>} Result object with created and skipped tables
*/
async function initDatabase() {
step('Initializing Zen database...');
try {
const authResult = await createAuthTables();
// Initialize modules
let modulesResult = { created: [], skipped: [] };
try {
const { initModules } = await import('../../modules/init.js');
modulesResult = await initModules();
} catch (error) {
// Modules might not be available or enabled
info('No modules to initialize or modules not available');
}
done(`DB ready — auth: ${authResult.created.length} created, modules: ${modulesResult.created.length} created, ${authResult.skipped.length + modulesResult.skipped.length} skipped`);
return {
created: [...authResult.created, ...modulesResult.created],
skipped: [...authResult.skipped, ...modulesResult.skipped],
success: true
};
} catch (error) {
fail(`DB initialization failed: ${error.message}`);
throw error;
}
}
/**
* Drop all Zen authentication tables (use with caution!)
* @returns {Promise<void>}
*/
async function dropAuthTables() {
const tables = [
'zen_auth_verifications',
'zen_auth_accounts',
'zen_auth_sessions',
'zen_auth_users'
];
warn('Dropping all Zen authentication tables...');
for (const tableName of tables) {
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
done(`Dropped table: ${tableName}`);
}
}
done('All authentication tables dropped');
}
export {
initDatabase,
createAuthTables,
tableExists,
dropAuthTables
};
+117
View File
@@ -0,0 +1,117 @@
/**
* Auth Feature - Database
* Creates and drops zen_auth_* tables.
*/
import { query, tableExists } from '@zen/core/database';
import { done, warn } from '../../shared/lib/logger.js';
const AUTH_TABLES = [
{
name: 'zen_auth_users',
sql: `
CREATE TABLE zen_auth_users (
id text NOT NULL PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE,
email_verified boolean NOT NULL DEFAULT false,
image text,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
role text DEFAULT 'user' CHECK (role IN ('admin', 'user'))
)
`
},
{
name: 'zen_auth_sessions',
sql: `
CREATE TABLE zen_auth_sessions (
id text NOT NULL PRIMARY KEY,
expires_at timestamptz NOT NULL,
token text NOT NULL UNIQUE,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz NOT NULL,
ip_address text,
user_agent text,
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE
)
`
},
{
name: 'zen_auth_accounts',
sql: `
CREATE TABLE zen_auth_accounts (
id text NOT NULL PRIMARY KEY,
account_id text NOT NULL,
provider_id text NOT NULL,
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE,
access_token text,
refresh_token text,
id_token text,
access_token_expires_at timestamptz,
refresh_token_expires_at timestamptz,
scope text,
password text,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz NOT NULL
)
`
},
{
name: 'zen_auth_verifications',
sql: `
CREATE TABLE zen_auth_verifications (
id text NOT NULL PRIMARY KEY,
identifier text NOT NULL,
value text NOT NULL,
token text NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`
}
];
/**
* Create all authentication tables.
* @returns {Promise<{ created: string[], skipped: string[] }>}
*/
export async function createTables() {
const created = [];
const skipped = [];
for (const table of AUTH_TABLES) {
const exists = await tableExists(table.name);
if (!exists) {
await query(table.sql);
created.push(table.name);
done(`Created table: ${table.name}`);
} else {
skipped.push(table.name);
}
}
return { created, skipped };
}
/**
* Drop all authentication tables in reverse dependency order.
* @returns {Promise<void>}
*/
export async function dropTables() {
const dropOrder = [...AUTH_TABLES].reverse().map(t => t.name);
warn('Dropping all Zen authentication tables...');
for (const tableName of dropOrder) {
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
done(`Dropped table: ${tableName}`);
}
}
done('All authentication tables dropped');
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Core Features Registry
*
* Lists all built-in features that are always initialized when running `zen-db init`.
* Unlike optional modules (src/modules), core features are not gated by env vars —
* they are required for the application to function.
*
* Each name must correspond to a directory under src/features/ that exposes a db.js
* with createTables() and optionally dropTables().
*/
export const CORE_FEATURES = [
'auth',
];
+65
View File
@@ -0,0 +1,65 @@
/**
* Core Feature Database Initialization (CLI)
*
* Initializes and drops DB tables for each core feature.
* Features are discovered from CORE_FEATURES — no manual wiring needed
* when adding a new feature.
*/
import { CORE_FEATURES } from './features.registry.js';
import { done, fail, info, step } from '../shared/lib/logger.js';
/**
* Initialize all core feature databases.
* @returns {Promise<{ created: string[], skipped: string[] }>}
*/
export async function initFeatures() {
const created = [];
const skipped = [];
step('Initializing feature databases...');
for (const featureName of CORE_FEATURES) {
try {
step(`Initializing ${featureName}...`);
const db = await import(`./${featureName}/db.js`);
if (typeof db.createTables === 'function') {
const result = await db.createTables();
if (result?.created) created.push(...result.created);
if (result?.skipped) skipped.push(...result.skipped);
done(`${featureName} initialized`);
} else {
info(`${featureName} has no createTables function`);
}
} catch (error) {
fail(`${featureName}: ${error.message}`);
throw error;
}
}
return { created, skipped };
}
/**
* Drop all core feature databases in reverse order.
* @returns {Promise<void>}
*/
export async function dropFeatures() {
for (const featureName of [...CORE_FEATURES].reverse()) {
try {
const db = await import(`./${featureName}/db.js`);
if (typeof db.dropTables === 'function') {
await db.dropTables();
} else {
info(`${featureName} has no dropTables function`);
}
} catch (error) {
fail(`${featureName}: ${error.message}`);
throw error;
}
}
}
+1 -13
View File
@@ -3,22 +3,10 @@
* Creates zen_posts and zen_posts_category tables. * Creates zen_posts and zen_posts_category tables.
*/ */
import { query } from '@zen/core/database'; import { query, tableExists } from '@zen/core/database';
import { getPostsConfig } from './config.js'; import { getPostsConfig } from './config.js';
import { done, info, step } from '../../shared/lib/logger.js'; import { done, info, step } from '../../shared/lib/logger.js';
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
async function createPostsCategoryTable() { async function createPostsCategoryTable() {
const tableName = 'zen_posts_category'; const tableName = 'zen_posts_category';
const exists = await tableExists(tableName); const exists = await tableExists(tableName);