// H3 (red-team): better-sqlite3-multiple-ciphers is an API-compatible drop-in for // better-sqlite3 that adds SQLCipher, so the whole DB file can be encrypted at rest with a // single `pragma('key=...')`. It ships its own DefinitelyTyped-derived declarations (identical // shape to @types/better-sqlite3), so `Database.Database` and `db.pragma()` type exactly as // before — no shim needed. See openDb() for the keying / dev-fallback logic. import Database from 'better-sqlite3-multiple-ciphers' import fs from 'node:fs' import path from 'node:path' /** * Store DB — SQLite for the slice, behind portable repositories (repos.ts). * The Oracle 12c adapter (D12) implements the same functions when the Classic * DDL arrives; SQL here stays standard where possible, quirks are commented. */ export type DB = Database.Database const SCHEMA = ` CREATE TABLE IF NOT EXISTS tenant ( id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL, gstin TEXT, gst_scheme TEXT NOT NULL DEFAULT 'regular', fy_start_month INTEGER NOT NULL DEFAULT 4, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS store ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, state_code TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS counter ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS app_user ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, roles TEXT NOT NULL, store_ids TEXT NOT NULL, language TEXT NOT NULL DEFAULT 'en', active INTEGER NOT NULL DEFAULT 1, pin_salt TEXT, pin_hash TEXT, pw_salt TEXT, pw_hash TEXT ); CREATE TABLE IF NOT EXISTS item ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, name_secondary TEXT, hsn TEXT NOT NULL DEFAULT '', tax_class_code TEXT NOT NULL, unit_code TEXT NOT NULL DEFAULT 'PCS', unit_decimals INTEGER NOT NULL DEFAULT 0, mrp_paise INTEGER, sale_price_paise INTEGER NOT NULL, price_includes_tax INTEGER NOT NULL DEFAULT 1, barcodes TEXT NOT NULL DEFAULT '[]', batch_tracked INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', UNIQUE (tenant_id, code) ); CREATE TABLE IF NOT EXISTS party ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL, phone TEXT, gstin TEXT, state_code TEXT, credit_limit_paise INTEGER ); CREATE TABLE IF NOT EXISTS tax_class ( class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL, cess_pct_bp INTEGER NOT NULL DEFAULT 0, effective_from TEXT NOT NULL, effective_to TEXT ); CREATE TABLE IF NOT EXISTS doc_series ( tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, counter_id TEXT NOT NULL, doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL, next_seq INTEGER NOT NULL, PRIMARY KEY (tenant_id, store_id, counter_id, doc_type, fy) ); CREATE TABLE IF NOT EXISTS bill ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, counter_id TEXT NOT NULL, doc_type TEXT NOT NULL, doc_no TEXT NOT NULL, business_date TEXT NOT NULL, cashier_id TEXT NOT NULL, shift_id TEXT NOT NULL, customer_id TEXT, ref_doc_id TEXT, gross_paise INTEGER NOT NULL, discount_paise INTEGER NOT NULL, taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL, sgst_paise INTEGER NOT NULL, igst_paise INTEGER NOT NULL, cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL, payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL, buyer_gstin TEXT, place_of_supply TEXT, client_id TEXT, payload TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '', device_seq INTEGER NOT NULL DEFAULT 0, UNIQUE (tenant_id, doc_no) ); CREATE TABLE IF NOT EXISTS stock_movement ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, item_id TEXT NOT NULL, qty_delta REAL NOT NULL, reason TEXT NOT NULL, ref_doc_id TEXT, batch_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL ); -- S5 batch/expiry: a batch is an item's lot with an optional expiry (nullable — -- non-perishables are batch-tracked without a date). Stock stays derived (R7): -- a batch's on-hand = SUM(stock_movement.qty_delta WHERE batch_id = the batch). CREATE TABLE IF NOT EXISTS batch ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT, created_at TEXT NOT NULL, UNIQUE (tenant_id, store_id, item_id, batch_no) ); CREATE TABLE IF NOT EXISTS setting ( scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, effective_from TEXT ); CREATE TABLE IF NOT EXISTS message_catalog ( code TEXT NOT NULL, channel TEXT NOT NULL, lang TEXT NOT NULL, template TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS audit_log ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL, user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL, entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT, -- H2 (red-team): per-tenant tamper-evident hash chain. entry_hash = -- sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity, -- entity_id, before_json, after_json)); prev_hash = the previous entry's -- entry_hash for the tenant (genesis = ''). Any out-of-band UPDATE/DELETE/INSERT -- to this table breaks the chain, which GET /api/audit/verify recomputes. This is -- tamper-EVIDENCE, not prevention — the file is still writable (that is H3's job). prev_hash TEXT, entry_hash TEXT ); CREATE TABLE IF NOT EXISTS outbox ( op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, device_seq INTEGER NOT NULL, doc_type TEXT NOT NULL, doc_id TEXT NOT NULL, envelope TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL ); -- Single-use supervisor-approval tokens (SEC fix): /auth/verify-pin mints one on a -- valid supervisor PIN; the bill commit redeems it (used=0 -> 1, age < 5 min, approver -- role re-checked) so the client can never forge who approved an override. -- H4 fix: the token is BOUND at mint to the exact override it authorises — -- item_id, kind, before_paise (server-computed master value) and after_paise. The -- commit re-verifies these against the specific line's server-computed deviation, so -- a "Milk ₹50→45" approval can never be re-attached to "Whiskey ₹2000→200". CREATE TABLE IF NOT EXISTS override_approval ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0, item_id TEXT, kind TEXT, before_paise INTEGER, after_paise INTEGER, cashier_user_id TEXT ); CREATE TABLE IF NOT EXISTS purchase ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, supplier_id TEXT NOT NULL, invoice_no TEXT NOT NULL, invoice_date TEXT NOT NULL, business_date TEXT NOT NULL, taxable_paise INTEGER NOT NULL, tax_paise INTEGER NOT NULL, total_paise INTEGER NOT NULL, created_by TEXT NOT NULL, created_at_wall TEXT NOT NULL, payload TEXT NOT NULL, UNIQUE (tenant_id, supplier_id, invoice_no) ); CREATE TABLE IF NOT EXISTS purchase_line ( purchase_id TEXT NOT NULL, line_no INTEGER NOT NULL, item_id TEXT NOT NULL, name TEXT NOT NULL, qty REAL NOT NULL, unit_cost_paise INTEGER NOT NULL, tax_rate_bp INTEGER NOT NULL, tax_paise INTEGER NOT NULL, line_total_paise INTEGER NOT NULL, new_sale_price_paise INTEGER, new_mrp_paise INTEGER, batch_id TEXT, PRIMARY KEY (purchase_id, line_no) ); CREATE INDEX IF NOT EXISTS idx_pline_item ON purchase_line (item_id); CREATE INDEX IF NOT EXISTS idx_item_name ON item (tenant_id, name); CREATE INDEX IF NOT EXISTS idx_bill_date ON bill (tenant_id, business_date); CREATE INDEX IF NOT EXISTS idx_move_item ON stock_movement (tenant_id, store_id, item_id); -- idx_move_batch lives in migrate() only: on an upgraded dev DB the batch_id column is -- added by ALTER there, so indexing it in SCHEMA (which runs first) would fail. The -- batch table is fully defined above, so its index is safe to keep here. CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id); CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (tenant_id, at_wall); ` /** * Additive migrations for dev DBs seeded before a column existed. Fresh DBs get * the columns from CREATE TABLE above; this only fills the gap for the running * slice. Each step is guarded by a PRAGMA table_info check so it is idempotent. * The Oracle adapter (D12) manages its own DDL versioning. */ function migrate(db: DB): void { const cols = db.prepare(`PRAGMA table_info(bill)`).all() as { name: string }[] const has = (c: string): boolean => cols.some((x) => x.name === c) // S3 B2B: buyer GSTIN + place-of-supply snapshot on the immutable bill. if (!has('buyer_gstin')) db.exec(`ALTER TABLE bill ADD COLUMN buyer_gstin TEXT`) if (!has('place_of_supply')) db.exec(`ALTER TABLE bill ADD COLUMN place_of_supply TEXT`) // S4 offline queue: the client bill id is the idempotency key for the reconnect // drain. It is NULL for normal (online) bills and non-null only for a bill that // was queued offline; a partial unique index enforces one committed bill per // (tenant, client id) so a retried drain can never duplicate a bill or its stock. // (A partial index, not a table UNIQUE, because SQLite ALTER TABLE cannot add a // constraint and NULLs must stay non-conflicting for the online path.) if (!has('client_id')) db.exec(`ALTER TABLE bill ADD COLUMN client_id TEXT`) db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bill_client ON bill (tenant_id, client_id) WHERE client_id IS NOT NULL`) // SEC fix — single-use supervisor-approval tokens. A whole new table, so the // idempotent guard is the table itself (CREATE TABLE IF NOT EXISTS, which SCHEMA // also runs on every open); re-stated here so the migration is explicit for dev // DBs seeded before the table existed. db.exec(`CREATE TABLE IF NOT EXISTS override_approval ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0 )`) // H4 fix — bind the approval token to the override it authorises. Additive columns // for dev DBs seeded before the binding existed; fresh DBs get them from SCHEMA above. const oaCols = db.prepare(`PRAGMA table_info(override_approval)`).all() as { name: string }[] const oaHas = (c: string): boolean => oaCols.some((x) => x.name === c) if (!oaHas('item_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN item_id TEXT`) if (!oaHas('kind')) db.exec(`ALTER TABLE override_approval ADD COLUMN kind TEXT`) if (!oaHas('before_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN before_paise INTEGER`) if (!oaHas('after_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN after_paise INTEGER`) if (!oaHas('cashier_user_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN cashier_user_id TEXT`) // S5 batch/expiry — the batch table (SCHEMA also creates it; re-stated so the // migration is explicit for dev DBs seeded before it existed), plus the nullable // batch_id columns on stock_movement and purchase_line. Per-batch on-hand stays // derived from movements (R7); batch_id is NULL for un-batched (non-tracked) stock. db.exec(`CREATE TABLE IF NOT EXISTS batch ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT, created_at TEXT NOT NULL, UNIQUE (tenant_id, store_id, item_id, batch_no) )`) const moveCols = db.prepare(`PRAGMA table_info(stock_movement)`).all() as { name: string }[] if (!moveCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE stock_movement ADD COLUMN batch_id TEXT`) const plineCols = db.prepare(`PRAGMA table_info(purchase_line)`).all() as { name: string }[] if (!plineCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE purchase_line ADD COLUMN batch_id TEXT`) db.exec(`CREATE INDEX IF NOT EXISTS idx_move_batch ON stock_movement (batch_id)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id)`) // H2 — tamper-evident audit hash chain. Additive columns for dev DBs seeded before the // chain existed; fresh DBs get them from SCHEMA above. Pre-existing rows stay NULL (they are // not retro-hashed) — on a real upgrade the chain is consistent from the first row written // after this migration; the dev DB is reset for this slice so every row is chained from genesis. const auditCols = db.prepare(`PRAGMA table_info(audit_log)`).all() as { name: string }[] const auditHas = (c: string): boolean => auditCols.some((x) => x.name === c) if (!auditHas('prev_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`) if (!auditHas('entry_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`) } /** * H3 (red-team): full-DB encryption at rest via SQLCipher. When SIMS_DB_KEY is set we key the * database BEFORE any read, so the file on disk — main db AND its WAL/SHM sidecars — is ciphertext. * A copied or snooped sims.db then yields no customer phone numbers/GSTINs (DPDP), no item/purchase * costs & margins, no sales history, no party ledgers and no PIN salts/hashes: every page is * encrypted under a key held ONLY in the server's environment — a machine-scoped secret (Windows * DPAPI / OS keychain), NEVER in the repo or the DB itself. * * When SIMS_DB_KEY is UNSET we open unencrypted exactly as before (dev fallback, so :5181 keeps * working) and warn ONCE that production MUST set it. Mirrors the SIMS_PIN_PEPPER pattern. * * The key must be applied immediately after opening, before journal_mode / schema touch the file * (SQLCipher derives the page key from it on first access). Single quotes in the secret are escaped * by doubling so an arbitrary passphrase is a safe SQL string literal. * * One-time migration of an EXISTING plaintext sims.db to encrypted (not needed for the empty dev DB, * which is simply recreated by deleting data/): open the plaintext file, then * ATTACH DATABASE 'sims-enc.db' AS enc KEY ''; * SELECT sqlcipher_export('enc'); DETACH DATABASE enc; * and swap sims-enc.db in. Do this offline, once, before first encrypted boot. */ let dbKeyWarned = false function applyKeyIfSet(db: DB): void { const key = process.env['SIMS_DB_KEY'] if (key === undefined || key === '') { if (!dbKeyWarned) { dbKeyWarned = true // eslint-disable-next-line no-console console.warn( '[store-server] SIMS_DB_KEY is not set — the store DB is written UNENCRYPTED on disk. A copied ' + 'sims.db then yields customer PII (phones/GSTINs), costs & margins, the full sales history, party ' + 'ledgers and PIN hashes. Production MUST set a machine-scoped SIMS_DB_KEY (SQLCipher at rest).', ) } return } db.pragma(`cipher='sqlcipher'`) db.pragma(`key='${key.replace(/'/g, "''")}'`) } /** * Open the store DB. Defaults to the on-disk WAL file; pass an explicit path * (e.g. `:memory:`) for hermetic tests. Same schema + migrations either way. */ export function openDb(dbPath?: string): DB { let file: string if (dbPath !== undefined) { file = dbPath } else { // SIMS_DATA_DIR lets a second instance run against an isolated data dir (e.g. the // S7 load target) without touching the dev DB; unset ⇒ the standard ../data path. const envDir = process.env['SIMS_DATA_DIR'] const dir = envDir !== undefined && envDir !== '' ? path.resolve(envDir) : path.resolve(__dirname, '../data') fs.mkdirSync(dir, { recursive: true }) file = path.join(dir, 'sims.db') } const db = new Database(file) applyKeyIfSet(db) // H3 — key BEFORE any read (journal_mode/schema touch the file) db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') db.exec(SCHEMA) migrate(db) return db }