skopix 2.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.
@@ -0,0 +1,47 @@
1
+ import fs from 'fs-extra';
2
+ import yaml from 'yaml';
3
+ import path from 'path';
4
+
5
+ /**
6
+ * Load credentials from a YAML file.
7
+ *
8
+ * Expected format:
9
+ * credentials:
10
+ * - label: "Main test account"
11
+ * fields:
12
+ * email: testuser@example.com
13
+ * password: secret
14
+ * username: testuser
15
+ *
16
+ * Returns a flat object keyed by label for easy lookup:
17
+ * {
18
+ * "Main test account": { email: "...", password: "...", username: "..." }
19
+ * }
20
+ */
21
+ export async function loadCredentials(filePath) {
22
+ const resolved = path.resolve(process.cwd(), filePath);
23
+
24
+ if (!await fs.pathExists(resolved)) {
25
+ throw new Error(`Credentials file not found: ${resolved}`);
26
+ }
27
+
28
+ const content = await fs.readFile(resolved, 'utf-8');
29
+ const parsed = yaml.parse(content);
30
+
31
+ if (!parsed || !parsed.credentials) {
32
+ throw new Error('Invalid credentials file. Expected "credentials:" key at root.');
33
+ }
34
+
35
+ const result = {};
36
+ for (const entry of parsed.credentials) {
37
+ if (entry.label && entry.fields) {
38
+ result[entry.label] = entry.fields;
39
+ }
40
+ }
41
+
42
+ if (Object.keys(result).length === 0) {
43
+ throw new Error('No valid credential entries found in file.');
44
+ }
45
+
46
+ return result;
47
+ }
package/core/db.js ADDED
@@ -0,0 +1,503 @@
1
+ // core/db.js — SQLite database for team mode (auth, users, sessions)
2
+ // In single-user mode this module is never loaded. In team mode it initialises
3
+ // the DB at ~/.skopix/skopix.db and exposes safe helpers.
4
+
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import fs from 'fs-extra';
8
+
9
+ let _db = null;
10
+ let _dbPath = null;
11
+
12
+ // Lazy-import better-sqlite3 so it isn't required for single-user installs.
13
+ // Users only need it when running with SKOPIX_TEAM_MODE=true.
14
+ async function _loadSqlite() {
15
+ try {
16
+ const mod = await import('better-sqlite3');
17
+ return mod.default;
18
+ } catch (err) {
19
+ throw new Error(
20
+ 'Team mode requires better-sqlite3. Install it with:\n npm install better-sqlite3\n\n' +
21
+ 'Or run without SKOPIX_TEAM_MODE for single-user mode.'
22
+ );
23
+ }
24
+ }
25
+
26
+ export async function initDb(dbPath) {
27
+ if (_db) return _db;
28
+
29
+ _dbPath = dbPath || path.join(os.homedir(), '.skopix', 'skopix.db');
30
+ await fs.ensureDir(path.dirname(_dbPath));
31
+
32
+ const Database = await _loadSqlite();
33
+ _db = new Database(_dbPath);
34
+
35
+ // Performance + safety tweaks
36
+ _db.pragma('journal_mode = WAL');
37
+ _db.pragma('foreign_keys = ON');
38
+
39
+ _runMigrations(_db);
40
+ return _db;
41
+ }
42
+
43
+ export function getDb() {
44
+ if (!_db) throw new Error('Database not initialised. Call initDb() first.');
45
+ return _db;
46
+ }
47
+
48
+ export function isDbReady() {
49
+ return _db !== null;
50
+ }
51
+
52
+ export function getDbPath() {
53
+ return _dbPath;
54
+ }
55
+
56
+ // ─── MIGRATIONS ──────────────────────────────────────────────────────────────
57
+ // Versioned schema migrations. To add a new one, append to the array.
58
+ // Existing migrations are immutable once shipped.
59
+ const MIGRATIONS = [
60
+ {
61
+ version: 2,
62
+ name: 'password reset tokens',
63
+ up: (db) => {
64
+ db.exec(`
65
+ CREATE TABLE IF NOT EXISTS password_resets (
66
+ token TEXT PRIMARY KEY,
67
+ user_id TEXT NOT NULL,
68
+ created_at TEXT NOT NULL,
69
+ expires_at TEXT NOT NULL,
70
+ used_at TEXT,
71
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
72
+ );
73
+ CREATE INDEX IF NOT EXISTS idx_password_resets_user ON password_resets(user_id);
74
+ `);
75
+ },
76
+ },
77
+ {
78
+ version: 1,
79
+ name: 'initial schema',
80
+ up: (db) => {
81
+ db.exec(`
82
+ CREATE TABLE IF NOT EXISTS users (
83
+ id TEXT PRIMARY KEY,
84
+ email TEXT UNIQUE NOT NULL,
85
+ name TEXT NOT NULL,
86
+ password_hash TEXT NOT NULL,
87
+ role TEXT NOT NULL CHECK(role IN ('admin','editor','viewer')),
88
+ created_at TEXT NOT NULL,
89
+ updated_at TEXT NOT NULL,
90
+ last_login_at TEXT,
91
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled'))
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS web_sessions (
95
+ token TEXT PRIMARY KEY,
96
+ user_id TEXT NOT NULL,
97
+ created_at TEXT NOT NULL,
98
+ expires_at TEXT NOT NULL,
99
+ last_used_at TEXT NOT NULL,
100
+ ip_address TEXT,
101
+ user_agent TEXT,
102
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
103
+ );
104
+
105
+ CREATE TABLE IF NOT EXISTS invites (
106
+ token TEXT PRIMARY KEY,
107
+ email TEXT NOT NULL,
108
+ role TEXT NOT NULL CHECK(role IN ('admin','editor','viewer')),
109
+ invited_by TEXT NOT NULL,
110
+ created_at TEXT NOT NULL,
111
+ expires_at TEXT NOT NULL,
112
+ accepted_at TEXT,
113
+ FOREIGN KEY(invited_by) REFERENCES users(id) ON DELETE CASCADE
114
+ );
115
+
116
+ CREATE TABLE IF NOT EXISTS audit_log (
117
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ user_id TEXT,
119
+ action TEXT NOT NULL,
120
+ target_type TEXT,
121
+ target_id TEXT,
122
+ metadata TEXT,
123
+ created_at TEXT NOT NULL,
124
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
125
+ );
126
+
127
+ CREATE TABLE IF NOT EXISTS user_secrets (
128
+ user_id TEXT NOT NULL,
129
+ key TEXT NOT NULL,
130
+ value_encrypted TEXT NOT NULL,
131
+ updated_at TEXT NOT NULL,
132
+ PRIMARY KEY(user_id, key),
133
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
134
+ );
135
+
136
+ CREATE INDEX IF NOT EXISTS idx_web_sessions_user ON web_sessions(user_id);
137
+ CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions(expires_at);
138
+ CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id);
139
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
140
+ `);
141
+ },
142
+ },
143
+ ];
144
+
145
+ function _runMigrations(db) {
146
+ db.exec(`
147
+ CREATE TABLE IF NOT EXISTS schema_migrations (
148
+ version INTEGER PRIMARY KEY,
149
+ name TEXT NOT NULL,
150
+ applied_at TEXT NOT NULL
151
+ );
152
+ `);
153
+
154
+ const applied = db.prepare('SELECT version FROM schema_migrations').all().map(r => r.version);
155
+ const insert = db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)');
156
+
157
+ // Run migrations in ascending version order
158
+ const sortedMigrations = [...MIGRATIONS].sort((a, b) => a.version - b.version);
159
+ for (const m of sortedMigrations) {
160
+ if (applied.includes(m.version)) continue;
161
+ const tx = db.transaction(() => {
162
+ m.up(db);
163
+ insert.run(m.version, m.name, new Date().toISOString());
164
+ });
165
+ tx();
166
+ }
167
+ }
168
+
169
+ // ─── HIGH-LEVEL HELPERS ──────────────────────────────────────────────────────
170
+ export function hasAnyAdmin() {
171
+ if (!_db) return false;
172
+ const row = _db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND status = 'active'").get();
173
+ return row.count > 0;
174
+ }
175
+
176
+ export function getUserById(id) {
177
+ if (!_db) return null;
178
+ return _db.prepare('SELECT * FROM users WHERE id = ?').get(id);
179
+ }
180
+
181
+ export function getUserByEmail(email) {
182
+ if (!_db) return null;
183
+ return _db.prepare('SELECT * FROM users WHERE email = ?').get(email);
184
+ }
185
+
186
+ export function listUsers() {
187
+ if (!_db) return [];
188
+ return _db.prepare('SELECT id, email, name, role, status, created_at, last_login_at FROM users ORDER BY created_at ASC').all();
189
+ }
190
+
191
+ export function createUser({ id, email, name, passwordHash, role, status }) {
192
+ if (!_db) throw new Error('DB not ready');
193
+ const now = new Date().toISOString();
194
+ _db.prepare(`
195
+ INSERT INTO users (id, email, name, password_hash, role, created_at, updated_at, status)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
197
+ `).run(id, email, name, passwordHash, role, now, now, status || 'active');
198
+ return getUserById(id);
199
+ }
200
+
201
+ export function updateUserLastLogin(id) {
202
+ if (!_db) return;
203
+ const now = new Date().toISOString();
204
+ _db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
205
+ }
206
+
207
+ export function createWebSession({ token, userId, expiresAt, ipAddress, userAgent }) {
208
+ if (!_db) throw new Error('DB not ready');
209
+ const now = new Date().toISOString();
210
+ _db.prepare(`
211
+ INSERT INTO web_sessions (token, user_id, created_at, expires_at, last_used_at, ip_address, user_agent)
212
+ VALUES (?, ?, ?, ?, ?, ?, ?)
213
+ `).run(token, userId, now, expiresAt, now, ipAddress || null, userAgent || null);
214
+ }
215
+
216
+ export function getWebSession(token) {
217
+ if (!_db) return null;
218
+ const row = _db.prepare('SELECT * FROM web_sessions WHERE token = ?').get(token);
219
+ if (!row) return null;
220
+ if (new Date(row.expires_at) < new Date()) {
221
+ _db.prepare('DELETE FROM web_sessions WHERE token = ?').run(token);
222
+ return null;
223
+ }
224
+ return row;
225
+ }
226
+
227
+ export function touchWebSession(token) {
228
+ if (!_db) return;
229
+ _db.prepare('UPDATE web_sessions SET last_used_at = ? WHERE token = ?').run(new Date().toISOString(), token);
230
+ }
231
+
232
+ export function deleteWebSession(token) {
233
+ if (!_db) return;
234
+ _db.prepare('DELETE FROM web_sessions WHERE token = ?').run(token);
235
+ }
236
+
237
+ export function logAudit({ userId, action, targetType, targetId, metadata }) {
238
+ if (!_db) return;
239
+ _db.prepare(`
240
+ INSERT INTO audit_log (user_id, action, target_type, target_id, metadata, created_at)
241
+ VALUES (?, ?, ?, ?, ?, ?)
242
+ `).run(
243
+ userId || null,
244
+ action,
245
+ targetType || null,
246
+ targetId || null,
247
+ metadata ? JSON.stringify(metadata) : null,
248
+ new Date().toISOString()
249
+ );
250
+ }
251
+
252
+ // Cleanup expired sessions (call periodically)
253
+ export function pruneExpiredSessions() {
254
+ if (!_db) return 0;
255
+ const result = _db.prepare('DELETE FROM web_sessions WHERE expires_at < ?').run(new Date().toISOString());
256
+ return result.changes;
257
+ }
258
+
259
+ // ─── USER MANAGEMENT ─────────────────────────────────────────────────────────
260
+ export function countAdmins() {
261
+ if (!_db) return 0;
262
+ const row = _db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND status = 'active'").get();
263
+ return row.count;
264
+ }
265
+
266
+ export function updateUserRole(userId, newRole) {
267
+ if (!_db) throw new Error('DB not ready');
268
+ const now = new Date().toISOString();
269
+ _db.prepare('UPDATE users SET role = ?, updated_at = ? WHERE id = ?').run(newRole, now, userId);
270
+ return getUserById(userId);
271
+ }
272
+
273
+ export function updateUserStatus(userId, newStatus) {
274
+ if (!_db) throw new Error('DB not ready');
275
+ const now = new Date().toISOString();
276
+ _db.prepare('UPDATE users SET status = ?, updated_at = ? WHERE id = ?').run(newStatus, now, userId);
277
+ return getUserById(userId);
278
+ }
279
+
280
+ export function deleteUser(userId) {
281
+ if (!_db) throw new Error('DB not ready');
282
+ // CASCADE deletes web_sessions and invites for this user
283
+ const result = _db.prepare('DELETE FROM users WHERE id = ?').run(userId);
284
+ return result.changes > 0;
285
+ }
286
+
287
+ // ─── INVITES ─────────────────────────────────────────────────────────────────
288
+ export function createInvite({ token, email, role, invitedBy, expiresAt }) {
289
+ if (!_db) throw new Error('DB not ready');
290
+ const now = new Date().toISOString();
291
+ _db.prepare(`
292
+ INSERT INTO invites (token, email, role, invited_by, created_at, expires_at)
293
+ VALUES (?, ?, ?, ?, ?, ?)
294
+ `).run(token, email, role, invitedBy, now, expiresAt);
295
+ return getInvite(token);
296
+ }
297
+
298
+ export function getInvite(token) {
299
+ if (!_db) return null;
300
+ const row = _db.prepare('SELECT * FROM invites WHERE token = ?').get(token);
301
+ if (!row) return null;
302
+ return row;
303
+ }
304
+
305
+ export function listInvites() {
306
+ if (!_db) return [];
307
+ // Use a simple query - resolve invited_by in JS for shim compatibility
308
+ const invites = _db.prepare('SELECT * FROM invites ORDER BY created_at DESC').all();
309
+ return invites.map(inv => {
310
+ const inviter = getUserById(inv.invited_by);
311
+ return {
312
+ ...inv,
313
+ invited_by_name: inviter?.name || null,
314
+ invited_by_email: inviter?.email || null,
315
+ };
316
+ });
317
+ }
318
+
319
+ export function deleteInvite(token) {
320
+ if (!_db) throw new Error('DB not ready');
321
+ const result = _db.prepare('DELETE FROM invites WHERE token = ?').run(token);
322
+ return result.changes > 0;
323
+ }
324
+
325
+ export function markInviteAccepted(token) {
326
+ if (!_db) throw new Error('DB not ready');
327
+ _db.prepare('UPDATE invites SET accepted_at = ? WHERE token = ?').run(new Date().toISOString(), token);
328
+ }
329
+
330
+ export function pruneExpiredInvites() {
331
+ if (!_db) return 0;
332
+ const result = _db.prepare('DELETE FROM invites WHERE expires_at < ? AND accepted_at IS NULL').run(new Date().toISOString());
333
+ return result.changes;
334
+ }
335
+
336
+ // ─── USER SECRETS (encrypted per-user tokens) ────────────────────────────────
337
+ // Stores encrypted strings keyed by (user_id, key). Encryption happens at the
338
+ // auth layer - this just persists already-encrypted ciphertext.
339
+ export function setUserSecret(userId, key, encryptedValue) {
340
+ if (!_db) throw new Error('DB not ready');
341
+ const now = new Date().toISOString();
342
+ // Upsert pattern using INSERT OR REPLACE (works in SQLite)
343
+ // First try update, then insert if no row affected
344
+ const existing = _db.prepare('SELECT user_id FROM user_secrets WHERE user_id = ? AND key = ?').get(userId, key);
345
+ if (existing) {
346
+ _db.prepare('UPDATE user_secrets SET value_encrypted = ?, updated_at = ? WHERE user_id = ? AND key = ?')
347
+ .run(encryptedValue, now, userId, key);
348
+ } else {
349
+ _db.prepare('INSERT INTO user_secrets (user_id, key, value_encrypted, updated_at) VALUES (?, ?, ?, ?)')
350
+ .run(userId, key, encryptedValue, now);
351
+ }
352
+ }
353
+
354
+ export function getUserSecret(userId, key) {
355
+ if (!_db) return null;
356
+ const row = _db.prepare('SELECT value_encrypted FROM user_secrets WHERE user_id = ? AND key = ?').get(userId, key);
357
+ return row ? row.value_encrypted : null;
358
+ }
359
+
360
+ export function getUserSecretKeys(userId) {
361
+ if (!_db) return [];
362
+ const rows = _db.prepare('SELECT key, updated_at FROM user_secrets WHERE user_id = ?').all(userId);
363
+ return rows.map(r => ({ key: r.key, updatedAt: r.updated_at }));
364
+ }
365
+
366
+ export function deleteUserSecret(userId, key) {
367
+ if (!_db) throw new Error('DB not ready');
368
+ const result = _db.prepare('DELETE FROM user_secrets WHERE user_id = ? AND key = ?').run(userId, key);
369
+ return result.changes > 0;
370
+ }
371
+
372
+ // ─── PASSWORD CHANGE ─────────────────────────────────────────────────────────
373
+ export function updateUserPassword(userId, newPasswordHash) {
374
+ if (!_db) throw new Error('DB not ready');
375
+ const now = new Date().toISOString();
376
+ _db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(newPasswordHash, now, userId);
377
+ }
378
+
379
+ // ─── AUDIT LOG ───────────────────────────────────────────────────────────────
380
+ // List audit events with optional filters. Returns most recent first.
381
+ // Filters: { userId, action, since (ISO string), limit (default 100, max 500) }
382
+ export function listAuditLog(filters = {}) {
383
+ if (!_db) return [];
384
+ const limit = Math.min(500, Math.max(1, filters.limit || 100));
385
+ // Build query in JS since complex WHERE compositions are awkward in raw SQL
386
+ // and our shim only handles simple ones. We fetch most-recent N, filter in JS.
387
+ // For real installs with thousands of rows this is fine — we paginate by `before` timestamp.
388
+ let sql = 'SELECT * FROM audit_log';
389
+ const params = [];
390
+ if (filters.since) {
391
+ sql += ' WHERE created_at > ?';
392
+ params.push(filters.since);
393
+ }
394
+ sql += ' ORDER BY created_at DESC LIMIT ?';
395
+ params.push(limit);
396
+ let rows = _db.prepare(sql).all(...params);
397
+
398
+ // JS-side filtering for user/action (so shim works)
399
+ if (filters.userId) rows = rows.filter(r => r.user_id === filters.userId);
400
+ if (filters.action) rows = rows.filter(r => r.action === filters.action);
401
+
402
+ // Enrich with user info (name + email) for display
403
+ return rows.map(r => {
404
+ const user = r.user_id ? getUserById(r.user_id) : null;
405
+ return {
406
+ id: r.id,
407
+ userId: r.user_id,
408
+ userName: user?.name || null,
409
+ userEmail: user?.email || null,
410
+ action: r.action,
411
+ targetType: r.target_type,
412
+ targetId: r.target_id,
413
+ metadata: r.metadata ? (() => { try { return JSON.parse(r.metadata); } catch { return null; } })() : null,
414
+ createdAt: r.created_at,
415
+ };
416
+ });
417
+ }
418
+
419
+ // ─── ACTIVE WEB SESSIONS (for admin view + force-logout) ─────────────────────
420
+ export function listActiveSessions() {
421
+ if (!_db) return [];
422
+ // Don't show expired ones
423
+ const now = new Date().toISOString();
424
+ const rows = _db.prepare('SELECT * FROM web_sessions WHERE expires_at > ? ORDER BY last_used_at DESC').all(now);
425
+ return rows.map(r => {
426
+ const user = getUserById(r.user_id);
427
+ return {
428
+ token: r.token, // we return the full token only to admins, used to revoke
429
+ userId: r.user_id,
430
+ userName: user?.name || null,
431
+ userEmail: user?.email || null,
432
+ userRole: user?.role || null,
433
+ createdAt: r.created_at,
434
+ expiresAt: r.expires_at,
435
+ lastUsedAt: r.last_used_at,
436
+ ipAddress: r.ip_address,
437
+ userAgent: r.user_agent,
438
+ };
439
+ });
440
+ }
441
+
442
+ // ─── PASSWORD RESET TOKENS ───────────────────────────────────────────────────
443
+ // We don't have SMTP, so password reset works via admin-generated link.
444
+ // Admin generates a reset token, gives the link to the user, user sets new password.
445
+ // Tokens stored in same invites table with a different role marker? No - cleaner to
446
+ // use a dedicated approach via the audit log + a short-lived web_session-like row.
447
+ // Simpler approach: piggy-back on invites table with role='password_reset' marker.
448
+ // But that conflates two concepts. Let's add a tiny new table.
449
+
450
+ // Note: we can't add a new table in a migration without a new migration version.
451
+ // Instead, we'll re-use the invites table with a special role marker.
452
+ // Schema: role is CHECK(role IN ('admin','editor','viewer')) — can't insert 'password_reset'.
453
+ // So we need a real new table. Add migration 2.
454
+
455
+ // For now, use a simpler approach: store reset tokens in user_secrets with a magic key
456
+ // keyed by the user themselves. Hacky but avoids schema changes.
457
+
458
+ // Actually cleanest: re-use the existing invites table but encode reset by setting
459
+ // email=user's_current_email, role=user's_current_role. The accept-invite endpoint
460
+ // already creates a fresh user — that would create a duplicate user. So we need
461
+ // a different endpoint: /api/invites/:token/reset that updates instead of creates.
462
+
463
+ // Decision: add migration 2 with a password_resets table.
464
+ // (See MIGRATIONS array above - this is the cleanest path.)
465
+ export function createPasswordReset({ token, userId, expiresAt }) {
466
+ if (!_db) throw new Error('DB not ready');
467
+ const now = new Date().toISOString();
468
+ _db.prepare(`
469
+ INSERT INTO password_resets (token, user_id, created_at, expires_at)
470
+ VALUES (?, ?, ?, ?)
471
+ `).run(token, userId, now, expiresAt);
472
+ return { token, userId, expiresAt };
473
+ }
474
+
475
+ export function getPasswordReset(token) {
476
+ if (!_db) return null;
477
+ const row = _db.prepare('SELECT * FROM password_resets WHERE token = ?').get(token);
478
+ if (!row) return null;
479
+ if (new Date(row.expires_at) < new Date()) {
480
+ _db.prepare('DELETE FROM password_resets WHERE token = ?').run(token);
481
+ return null;
482
+ }
483
+ if (row.used_at) return null; // already used
484
+ return row;
485
+ }
486
+
487
+ export function markPasswordResetUsed(token) {
488
+ if (!_db) throw new Error('DB not ready');
489
+ _db.prepare('UPDATE password_resets SET used_at = ? WHERE token = ?').run(new Date().toISOString(), token);
490
+ }
491
+
492
+ export function pruneExpiredPasswordResets() {
493
+ if (!_db) return 0;
494
+ const result = _db.prepare('DELETE FROM password_resets WHERE expires_at < ? AND used_at IS NULL').run(new Date().toISOString());
495
+ return result.changes;
496
+ }
497
+
498
+ // Revoke all sessions for a user (used when forcing logout)
499
+ export function revokeAllUserSessions(userId) {
500
+ if (!_db) throw new Error('DB not ready');
501
+ const result = _db.prepare('DELETE FROM web_sessions WHERE user_id = ?').run(userId);
502
+ return result.changes;
503
+ }