You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
286 lines
16 KiB
TypeScript
286 lines
16 KiB
TypeScript
// 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 '<SIMS_DB_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
|
|
}
|