File size: 8,633 Bytes
7a027b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
/**
 * MedOS database layer — SQLite via better-sqlite3.
 *
 * Architecture:
 *   - SQLite, single file at `$DB_PATH` (/data/medos.db on HF Spaces).
 *   - WAL mode for concurrent reads during SSE streaming.
 *   - Auto-migration via PRAGMA user_version.
 *   - All queries use parameterized statements (no SQL injection).
 *
 * Migration history:
 *   v1  initial schema (users, sessions, health_data, chat_history)
 *   v2  per-user isolation: user_settings, audit_log, scan_log
 *   v3  admin user-management flags: is_active, last_login_at,
 *       disabled_reason  (ADDITIVE — new columns default to a value that
 *       leaves every v2 user indistinguishable from the pre-v3 state)
 */

import Database from 'better-sqlite3';
import { randomUUID, randomInt } from 'crypto';

const DB_PATH = process.env.DB_PATH || '/data/medos.db';

let _db: Database.Database | null = null;

export function getDb(): Database.Database {
  if (_db) return _db;
  _db = new Database(DB_PATH);
  _db.pragma('journal_mode = WAL');
  _db.pragma('busy_timeout = 5000');
  _db.pragma('foreign_keys = ON');
  runMigrations(_db);
  seedAdmin();
  return _db;
}

// ============================================================
// Migrations — version-gated, idempotent, additive
// ============================================================

function runMigrations(db: Database.Database): void {
  const version = db.pragma('user_version', { simple: true }) as number;

  if (version < 1) {
    db.exec(`
      CREATE TABLE IF NOT EXISTS users (
        id                    TEXT PRIMARY KEY,
        email                 TEXT UNIQUE NOT NULL COLLATE NOCASE,
        password              TEXT NOT NULL,
        display_name          TEXT,
        email_verified        INTEGER DEFAULT 0,
        is_admin              INTEGER DEFAULT 0,
        verification_code     TEXT,
        verification_expires  TEXT,
        reset_token           TEXT,
        reset_expires         TEXT,
        created_at            TEXT DEFAULT (datetime('now')),
        updated_at            TEXT DEFAULT (datetime('now'))
      );

      CREATE TABLE IF NOT EXISTS sessions (
        token       TEXT PRIMARY KEY,
        user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        expires_at  TEXT NOT NULL,
        created_at  TEXT DEFAULT (datetime('now'))
      );

      CREATE TABLE IF NOT EXISTS health_data (
        id          TEXT PRIMARY KEY,
        user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        type        TEXT NOT NULL,
        data        TEXT NOT NULL,
        created_at  TEXT DEFAULT (datetime('now')),
        updated_at  TEXT DEFAULT (datetime('now'))
      );

      CREATE TABLE IF NOT EXISTS chat_history (
        id          TEXT PRIMARY KEY,
        user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        preview     TEXT,
        messages    TEXT NOT NULL,
        topic       TEXT,
        created_at  TEXT DEFAULT (datetime('now'))
      );

      CREATE INDEX IF NOT EXISTS idx_health_user_type ON health_data(user_id, type);
      CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_history(user_id, created_at DESC);
      CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
      CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);

      PRAGMA user_version = 1;
    `);
  }

  if (version < 2) {
    // v2: per-user isolation tables.
    //
    // user_settings   one row per user; holds preferences (language,
    //                 country, units, theme, default model) plus the
    //                 EHR profile JSON and an OPTIONAL BYO Hugging
    //                 Face token stored encrypted at rest.
    //
    // audit_log       append-only security/forensic log. See lib/audit.ts.
    //
    // scan_log        per-call accounting for /api/scan so admins can
    //                 see usage by user and detect runaway costs on the
    //                 shared HF_TOKEN_INFERENCE quota.
    db.exec(`
      CREATE TABLE IF NOT EXISTS user_settings (
        user_id            TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
        language           TEXT,
        country            TEXT,
        units              TEXT,
        default_model      TEXT,
        theme              TEXT,
        ehr                TEXT NOT NULL DEFAULT '{}',
        hf_token_encrypted TEXT,
        updated_at         TEXT DEFAULT (datetime('now'))
      );

      CREATE TABLE IF NOT EXISTS audit_log (
        id          TEXT PRIMARY KEY,
        user_id     TEXT,
        action      TEXT NOT NULL,
        ip          TEXT,
        meta        TEXT NOT NULL DEFAULT '{}',
        created_at  TEXT DEFAULT (datetime('now'))
      );
      CREATE INDEX IF NOT EXISTS idx_audit_user_time ON audit_log(user_id, created_at DESC);
      CREATE INDEX IF NOT EXISTS idx_audit_action_time ON audit_log(action, created_at DESC);

      CREATE TABLE IF NOT EXISTS scan_log (
        id          TEXT PRIMARY KEY,
        user_id     TEXT,
        ip          TEXT,
        status      INTEGER NOT NULL,
        bytes       INTEGER NOT NULL DEFAULT 0,
        latency_ms  INTEGER NOT NULL DEFAULT 0,
        model       TEXT,
        created_at  TEXT DEFAULT (datetime('now'))
      );
      CREATE INDEX IF NOT EXISTS idx_scan_user_time ON scan_log(user_id, created_at DESC);

      PRAGMA user_version = 2;
    `);
  }

  if (version < 3) {
    // v3 — admin user-management flags.
    //
    // Every column is added with a DEFAULT so existing rows are fully
    // populated without a backfill step. We use column-presence probes
    // (PRAGMA table_info) because ALTER TABLE ADD COLUMN is not
    // idempotent on SQLite and we want the migration to survive
    // partial-failure re-runs.
    //
    // NOTE: `is_active` defaults to 1, so all pre-v3 users remain
    // enabled on upgrade. Admins can deactivate later via the
    // user-management endpoint added in the same release.
    const cols = db
      .prepare(`PRAGMA table_info(users)`)
      .all() as Array<{ name: string }>;
    const has = (name: string) => cols.some((c) => c.name === name);

    if (!has('is_active')) {
      db.exec(`ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;`);
    }
    if (!has('last_login_at')) {
      db.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT;`);
    }
    if (!has('disabled_reason')) {
      db.exec(`ALTER TABLE users ADD COLUMN disabled_reason TEXT;`);
    }
    db.exec(`
      CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
      PRAGMA user_version = 3;
    `);
  }
}

// ============================================================
// Helpers
// ============================================================

export function genId(): string {
  return randomUUID();
}

export function genToken(): string {
  return randomUUID() + '-' + randomUUID();
}

/** 6-digit numeric verification code. */
export function genVerificationCode(): string {
  return String(randomInt(100000, 999999));
}

/** 15 minutes from now (for verification codes). */
export function codeExpiry(): string {
  return new Date(Date.now() + 15 * 60 * 1000).toISOString();
}

/** 1 hour from now (for password reset tokens). */
export function resetExpiry(): string {
  return new Date(Date.now() + 60 * 60 * 1000).toISOString();
}

/** 30 days from now (for sessions). */
export function sessionExpiry(): string {
  return new Date(Date.now() + 30 * 86400 * 1000).toISOString();
}

export function pruneExpiredSessions(): void {
  const db = getDb();
  db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run();
}

/**
 * Seed the default admin account on first start. The admin email is
 * read from ADMIN_EMAIL env (default: admin@medos.health) and the
 * initial password from ADMIN_PASSWORD (default: admin123456).
 *
 * Change the password immediately after first login.
 */
export function seedAdmin(): void {
  const db = getDb();
  const adminEmail = (process.env.ADMIN_EMAIL || 'admin@medos.health').toLowerCase();
  const adminPassword = process.env.ADMIN_PASSWORD || 'admin123456';

  const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(adminEmail);
  if (existing) return; // Already seeded.

  const bcrypt = require('bcryptjs');
  const id = genId();
  const hash = bcrypt.hashSync(adminPassword, 10);

  db.prepare(
    `INSERT INTO users (id, email, password, display_name, email_verified, is_admin)
     VALUES (?, ?, ?, ?, 1, 1)`,
  ).run(id, adminEmail, hash, 'Admin');

  console.log(`[Admin] Default admin seeded: ${adminEmail}`);
}