scdb-core 1.0.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.
Files changed (4) hide show
  1. package/auth.js +113 -0
  2. package/index.js +126 -0
  3. package/package.json +10 -0
  4. package/schema.js +27 -0
package/auth.js ADDED
@@ -0,0 +1,113 @@
1
+ import bcrypt from 'bcryptjs';
2
+ import crypto from 'crypto';
3
+
4
+ const SALT_ROUNDS = 10;
5
+ const API_KEY_BYTES = 32;
6
+
7
+ /**
8
+ * Hash a password for storage.
9
+ * @param {string} password
10
+ * @returns {Promise<string>}
11
+ */
12
+ export async function hashPassword(password) {
13
+ return bcrypt.hash(password, SALT_ROUNDS);
14
+ }
15
+
16
+ /**
17
+ * Verify password against hash.
18
+ * @param {string} password
19
+ * @param {string} hash
20
+ * @returns {Promise<boolean>}
21
+ */
22
+ export async function verifyPassword(password, hash) {
23
+ return bcrypt.compare(password, hash);
24
+ }
25
+
26
+ /**
27
+ * Hash an API key for storage (we only store hash, not plain key).
28
+ * @param {string} key
29
+ * @returns {string}
30
+ */
31
+ export function hashApiKey(key) {
32
+ return crypto.createHash('sha256').update(key, 'utf8').digest('hex');
33
+ }
34
+
35
+ /**
36
+ * Generate a new API key (returns plain key; caller hashes for storage).
37
+ * @returns {string}
38
+ */
39
+ export function generateApiKey(port) {
40
+ return "::::" + port + "::::" + crypto.randomBytes(API_KEY_BYTES).toString('hex');
41
+ }
42
+
43
+ /**
44
+ * Authenticate by username/password. Returns user row with role or null.
45
+ * @param {ReturnType<import('./index.js').openDatabase> extends Promise<infer T> ? T : never} db - opened db from openDatabase()
46
+ * @param {string} username
47
+ * @param {string} password
48
+ * @returns {Promise<{ id: number, username: string, role: string } | null>}
49
+ */
50
+ export async function authenticateUser(db, username, password) {
51
+ const rows = db.query(
52
+ 'SELECT id, username, password_hash, role FROM users WHERE username = ?',
53
+ [username]
54
+ );
55
+ if (rows.length === 0) return null;
56
+ const user = rows[0];
57
+ const ok = await verifyPassword(password, user.password_hash);
58
+ if (!ok) return null;
59
+ return { id: user.id, username: user.username, role: user.role };
60
+ }
61
+
62
+ /**
63
+ * Authenticate by API key. Returns { id, name, role } or null.
64
+ * @param {ReturnType<import('./index.js').openDatabase> extends Promise<infer T> ? T : never} db
65
+ * @param {string} key - raw API key
66
+ * @returns {{ id: number, name: string | null, role: string } | null}
67
+ */
68
+ export function authenticateApiKey(db, key) {
69
+ const keyHash = hashApiKey(key);
70
+ const rows = db.query(
71
+ 'SELECT id, name, role FROM api_keys WHERE key_hash = ?',
72
+ [keyHash]
73
+ );
74
+ if (rows.length === 0) return null;
75
+ const r = rows[0];
76
+ return { id: r.id, name: r.name ?? null, role: r.role };
77
+ }
78
+
79
+ const ROLES = ['admin', 'readwrite', 'readonly'];
80
+
81
+ /**
82
+ * Check if role is allowed to run a given SQL (rough: only SELECT vs write).
83
+ * @param {string} role
84
+ * @param {string} sql - trimmed uppercase SQL
85
+ * @returns {boolean}
86
+ */
87
+ export function canRunSql(role, sql) {
88
+ const trimmed = sql.trim().toUpperCase();
89
+ const isWrite = /^(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|REPLACE)\s/.test(trimmed);
90
+ if (!isWrite) return true; // SELECT and read-only statements
91
+ if (role === 'admin' || role === 'readwrite') return true;
92
+ return false; // readonly cannot write
93
+ }
94
+
95
+ /**
96
+ * Write an audit log entry. principal_type: 'user' | 'api_key', action: e.g. 'query', 'execute'.
97
+ * @param {ReturnType<import('./index.js').openDatabase> extends Promise<infer T> ? T : never} db
98
+ * @param {{ principalType: 'user'|'api_key', principalId: number, role: string, action: string, queryPreview?: string, success: boolean }} entry
99
+ */
100
+ export function auditLog(db, entry) {
101
+ const preview = (entry.queryPreview || '').slice(0, 500);
102
+ db.execute(
103
+ `INSERT INTO audit_log (principal_type, principal_id, role, action, query_preview, success) VALUES (?, ?, ?, ?, ?, ?)`,
104
+ [
105
+ entry.principalType,
106
+ entry.principalId,
107
+ entry.role,
108
+ entry.action,
109
+ preview,
110
+ entry.success ? 1 : 0,
111
+ ]
112
+ );
113
+ }
package/index.js ADDED
@@ -0,0 +1,126 @@
1
+ import initSqlJs from 'sql.js';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ let SQL = null;
9
+
10
+ async function getSQL() {
11
+ if (SQL) return SQL;
12
+ SQL = await initSqlJs();
13
+ return SQL;
14
+ }
15
+
16
+ const MIGRATIONS = `
17
+ -- Users (web panel login)
18
+ CREATE TABLE IF NOT EXISTS users (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ username TEXT UNIQUE NOT NULL,
21
+ password_hash TEXT NOT NULL,
22
+ role TEXT NOT NULL CHECK(role IN ('admin','readwrite','readonly')),
23
+ created_at TEXT DEFAULT (datetime('now'))
24
+ );
25
+
26
+ -- API keys (for lib and CLI)
27
+ CREATE TABLE IF NOT EXISTS api_keys (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ key_hash TEXT UNIQUE NOT NULL,
30
+ name TEXT,
31
+ role TEXT NOT NULL CHECK(role IN ('admin','readwrite','readonly')),
32
+ created_at TEXT DEFAULT (datetime('now')),
33
+ owner_user_id INTEGER REFERENCES users(id)
34
+ );
35
+
36
+ -- Audit log
37
+ CREATE TABLE IF NOT EXISTS audit_log (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ principal_type TEXT NOT NULL CHECK(principal_type IN ('user','api_key')),
40
+ principal_id INTEGER NOT NULL,
41
+ role TEXT NOT NULL,
42
+ action TEXT NOT NULL,
43
+ query_preview TEXT,
44
+ success INTEGER NOT NULL,
45
+ created_at TEXT DEFAULT (datetime('now'))
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
49
+ CREATE INDEX IF NOT EXISTS idx_audit_principal ON audit_log(principal_type, principal_id);
50
+ `;
51
+
52
+ /**
53
+ * Open or create database from file path. Call once at startup.
54
+ * @param {string} dbPath - Path to .sqlite file (created if missing)
55
+ * @returns {Promise<{ query, execute, save, db }>}
56
+ */
57
+ export async function openDatabase(dbPath) {
58
+ const Sql = await getSQL();
59
+ let db;
60
+ const resolved = path.resolve(process.cwd(), dbPath);
61
+ const dir = path.dirname(resolved);
62
+ if (!fs.existsSync(dir)) {
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ }
65
+ if (fs.existsSync(resolved)) {
66
+ const buf = fs.readFileSync(resolved);
67
+ db = new Sql.Database(new Uint8Array(buf));
68
+ } else {
69
+ db = new Sql.Database();
70
+ }
71
+ db.run(MIGRATIONS);
72
+
73
+ try {
74
+ db.run('ALTER TABLE api_keys ADD COLUMN owner_user_id INTEGER REFERENCES users(id)');
75
+ } catch (_) {
76
+ // column already exists
77
+ }
78
+
79
+ return {
80
+ db,
81
+
82
+ /**
83
+ * Run a parameterized SELECT; returns array of row objects.
84
+ * @param {string} sql
85
+ * @param {unknown[]} params
86
+ * @returns {Record<string, unknown>[]}
87
+ */
88
+ query(sql, params = []) {
89
+ const stmt = db.prepare(sql);
90
+ try {
91
+ if (params.length) stmt.bind(params);
92
+ const rows = [];
93
+ while (stmt.step()) rows.push(stmt.getAsObject());
94
+ return rows;
95
+ } finally {
96
+ stmt.free();
97
+ }
98
+ },
99
+
100
+ /**
101
+ * Run a parameterized INSERT/UPDATE/DELETE; returns { changes, lastInsertRowid }.
102
+ * @param {string} sql
103
+ * @param {unknown[]} params
104
+ * @returns {{ changes: number, lastInsertRowid: number }}
105
+ */
106
+ execute(sql, params = []) {
107
+ db.run(sql, params);
108
+ return {
109
+ changes: db.getRowsModified(),
110
+ lastInsertRowid: db.exec("SELECT last_insert_rowid()")[0]?.values?.[0]?.[0] ?? 0,
111
+ };
112
+ },
113
+
114
+ /**
115
+ * Persist database to the file.
116
+ */
117
+ save() {
118
+ const data = db.export();
119
+ fs.writeFileSync(resolved, Buffer.from(data));
120
+ },
121
+ };
122
+ }
123
+
124
+ export { getSQL };
125
+ export * from './auth.js';
126
+ export * from './schema.js';
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "scdb-core",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "bcryptjs": "^2.4.3",
8
+ "sql.js": "^1.10.0"
9
+ }
10
+ }
package/schema.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * List all user tables (exclude sqlite_*).
3
+ * @param {import('./index.js').openDatabase extends () => Promise<infer T> ? T : never} db
4
+ * @returns {{ name: string }[]}
5
+ */
6
+ export function listTables(db) {
7
+ return db.query(
8
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`
9
+ );
10
+ }
11
+
12
+ /**
13
+ * Get column info for a table.
14
+ * @param {import('./index.js').openDatabase extends () => Promise<infer T> ? T : never} db
15
+ * @param {string} tableName
16
+ * @returns {{ name: string, type: string }[]}
17
+ */
18
+ export function tableColumns(db, tableName) {
19
+ return db.query(`PRAGMA table_info(${quoteTableName(tableName)})`);
20
+ }
21
+
22
+ function quoteTableName(name) {
23
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
24
+ throw new Error('Invalid table name');
25
+ }
26
+ return `"${name}"`;
27
+ }