elpaypg 0.0.0-stage → 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.
Files changed (90) hide show
  1. package/.env.example +8 -0
  2. package/README.md +176 -2
  3. package/package.json +21 -4
  4. package/public/elpay-checkout.js +769 -0
  5. package/public/elpay-sms-gateway.apk +0 -0
  6. package/public/elpay-sms-gateway.apk.sha256 +1 -0
  7. package/public/elpay.js +785 -0
  8. package/public/fonts/dm-sans.woff2 +0 -0
  9. package/public/fonts/sora.woff2 +0 -0
  10. package/public/views/checkout.html +2839 -0
  11. package/public/views/partials/auth.html +110 -0
  12. package/public/views/partials/footer.html +54 -0
  13. package/public/views/partials/header.html +101 -0
  14. package/public/views/partials/modals.html +179 -0
  15. package/public/views/partials/payment-methods.html +526 -0
  16. package/public/views/partials/sidebar.html +75 -0
  17. package/public/views/partials/splash.html +35 -0
  18. package/src/__tests__/smoke.test.js +8 -0
  19. package/src/auth-guard.js +95 -0
  20. package/src/index.js +137 -0
  21. package/src/router.js +46 -0
  22. package/src/routes/admin.routes.js +2421 -0
  23. package/src/routes/checkout.routes.js +2000 -0
  24. package/src/routes/customer.routes.js +650 -0
  25. package/src/routes/device.routes.js +303 -0
  26. package/src/routes/payments.routes.js +446 -0
  27. package/src/routes/pollers.routes.js +653 -0
  28. package/src/services/observer-poller.service.js +369 -0
  29. package/src/services/tunnel-manager.js +130 -0
  30. package/src/services/whatsapp-berry.service.js +743 -0
  31. package/src/vendor/common/__tests__/common.test.js +297 -0
  32. package/src/vendor/common/__tests__/common.test.ts +160 -0
  33. package/src/vendor/common/contracts.js +64 -0
  34. package/src/vendor/common/contracts.ts +188 -0
  35. package/src/vendor/common/crypto.js +132 -0
  36. package/src/vendor/common/crypto.ts +112 -0
  37. package/src/vendor/common/enums.js +91 -0
  38. package/src/vendor/common/enums.ts +91 -0
  39. package/src/vendor/common/index.js +12 -0
  40. package/src/vendor/common/index.ts +8 -0
  41. package/src/vendor/common/money.js +62 -0
  42. package/src/vendor/common/money.ts +56 -0
  43. package/src/vendor/common/observation.js +27 -0
  44. package/src/vendor/common/observation.ts +52 -0
  45. package/src/vendor/common/runtime-config.js +246 -0
  46. package/src/vendor/common/secrets.js +374 -0
  47. package/src/vendor/common/state-machine.d.ts +1 -0
  48. package/src/vendor/common/state-machine.js +1 -0
  49. package/src/vendor/common/state-transitions.js +85 -0
  50. package/src/vendor/common/tunnel-command.js +59 -0
  51. package/src/vendor/common/upi-providers.js +389 -0
  52. package/src/vendor/common/upi-providers.ts +400 -0
  53. package/src/vendor/common/upi.js +69 -0
  54. package/src/vendor/common/upi.ts +61 -0
  55. package/src/vendor/core/__tests__/core.test.js +683 -0
  56. package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
  57. package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
  58. package/src/vendor/core/attribution-engine.js +855 -0
  59. package/src/vendor/core/bank-email-parser.js +105 -0
  60. package/src/vendor/core/bank-sms-parser.js +183 -0
  61. package/src/vendor/core/device-security.js +506 -0
  62. package/src/vendor/core/index.js +13 -0
  63. package/src/vendor/core/normalizer.js +165 -0
  64. package/src/vendor/core/qr-lib.js +8 -0
  65. package/src/vendor/core/qr.js +62 -0
  66. package/src/vendor/core/queue-engine.js +153 -0
  67. package/src/vendor/core/recovery-engine.js +159 -0
  68. package/src/vendor/core/state-machine.js +42 -0
  69. package/src/vendor/core/timing-engine.js +272 -0
  70. package/src/vendor/core/verification-pipeline.js +1164 -0
  71. package/src/vendor/core/webhook-engine.js +280 -0
  72. package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
  73. package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
  74. package/src/vendor/db/__tests__/db.test.js +265 -0
  75. package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
  76. package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
  77. package/src/vendor/db/database.js +181 -0
  78. package/src/vendor/db/index.js +12 -0
  79. package/src/vendor/db/migrate.js +210 -0
  80. package/src/vendor/db/repositories/audit-log.repository.js +68 -0
  81. package/src/vendor/db/repositories/customer-session.repository.js +181 -0
  82. package/src/vendor/db/repositories/customer.repository.js +636 -0
  83. package/src/vendor/db/repositories/device.repository.js +285 -0
  84. package/src/vendor/db/repositories/merchant.repository.js +158 -0
  85. package/src/vendor/db/repositories/observation.repository.js +124 -0
  86. package/src/vendor/db/repositories/payment-session.repository.js +618 -0
  87. package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
  88. package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
  89. package/src/vendor/db/schema.sql +266 -0
  90. package/src/vendor/db/sqlite-schema.sql +270 -0
@@ -0,0 +1,210 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import crypto from 'node:crypto';
5
+ import { getCoinStoreWebhookSecret, LEGACY_DEFAULT_WEBHOOK_SECRET } from '../common/index.js';
6
+ import { getDatabase } from './database.js';
7
+ import { MerchantRepository } from './repositories/merchant.repository.js';
8
+ import { ReceivingAccountRepository } from './repositories/receiving-account.repository.js';
9
+ import { CustomerRepository } from './repositories/customer.repository.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
14
+ /**
15
+ * Runs database migrations and applies base seeds.
16
+ * @param {import('./database.js').Database} [targetDb]
17
+ */
18
+ export function runMigrations(targetDb) {
19
+ const db = targetDb || getDatabase();
20
+ const schemaPath = path.join(__dirname, 'sqlite-schema.sql');
21
+ const schemaSql = fs.readFileSync(schemaPath, 'utf8');
22
+
23
+ // Detect if customers table has an obsolete schema (phone_number/merchant_id instead of email/mobile_number, or password_hash NOT NULL).
24
+ // The old table is only rebuilt when it holds no customers: a schema mismatch must never destroy customer data.
25
+ try {
26
+ const tableInfo = db.query ? db.query("PRAGMA table_info(customers)") : [];
27
+ const colNames = (tableInfo || []).map(c => c.name);
28
+ const hasOldColumns = colNames.length > 0 && (
29
+ !colNames.includes('email') ||
30
+ colNames.includes('phone_number') ||
31
+ colNames.includes('merchant_id') ||
32
+ !colNames.includes('otp_code') ||
33
+ tableInfo.some(c => c.name === 'password_hash' && c.notnull === 1)
34
+ );
35
+ if (hasOldColumns) {
36
+ const customerCount = Number(db.queryOne('SELECT COUNT(*) AS n FROM customers')?.n || 0);
37
+ if (customerCount === 0) {
38
+ try { db.exec("PRAGMA foreign_keys = OFF;"); } catch {}
39
+ try { db.exec("DROP TABLE IF EXISTS customer_sessions;"); } catch {}
40
+ try { db.exec("DROP TABLE IF EXISTS customers;"); } catch {}
41
+ try { db.exec("PRAGMA foreign_keys = ON;"); } catch {}
42
+ } else {
43
+ console.error(`[migrate] customers table has an obsolete schema but holds ${customerCount} row(s); leaving it untouched. Back up and migrate it manually.`);
44
+ }
45
+ }
46
+ } catch (err) {
47
+ console.error('[migrate] Could not inspect customers table schema; skipping rebuild:', err.message);
48
+ }
49
+
50
+ // Fallback alter columns if customers/sessions table exists without new columns
51
+ try { db.exec("ALTER TABLE customers ADD COLUMN email TEXT;"); } catch {}
52
+ try { db.exec("ALTER TABLE customers ADD COLUMN mobile_number TEXT;"); } catch {}
53
+ try { db.exec("ALTER TABLE customers ADD COLUMN otp_code TEXT;"); } catch {}
54
+ try { db.exec("ALTER TABLE customers ADD COLUMN otp_expires_at TEXT;"); } catch {}
55
+ try { db.exec("ALTER TABLE customers ADD COLUMN otp_attempts INTEGER NOT NULL DEFAULT 0;"); } catch {}
56
+ try { db.exec("ALTER TABLE customers ADD COLUMN is_phone_verified INTEGER DEFAULT 0;"); } catch {}
57
+ try { db.exec("ALTER TABLE customers ADD COLUMN ban_reason TEXT;"); } catch {}
58
+ try { db.exec("ALTER TABLE customers ADD COLUMN banned_at TEXT;"); } catch {}
59
+ try { db.exec("ALTER TABLE customers ADD COLUMN unbanned_at TEXT;"); } catch {}
60
+ try { db.exec("ALTER TABLE customers ADD COLUMN last_device TEXT;"); } catch {}
61
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN device_id TEXT;"); } catch {}
62
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN verification_level INTEGER DEFAULT 0;"); } catch {}
63
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN verification_sources TEXT DEFAULT '[]';"); } catch {}
64
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN verification_tag TEXT DEFAULT 'UNVERIFIED';"); } catch {}
65
+
66
+ // Execute schema DDL
67
+ db.exec(schemaSql);
68
+
69
+ // Alter table columns if migrating existing databases
70
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN verification_level INTEGER DEFAULT 0;"); } catch {}
71
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN verification_sources TEXT DEFAULT '[]';"); } catch {}
72
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN verification_tag TEXT DEFAULT 'UNVERIFIED';"); } catch {}
73
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN attempt_id TEXT;"); } catch {}
74
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_single_active_session_per_order ON payment_sessions (merchant_id, order_id) WHERE status NOT IN ('FAILED', 'EXPIRED', 'CANCELLED');"); } catch {}
75
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_one_slot_per_receiving_account ON payment_sessions (receiving_account_id) WHERE status IN ('ACTIVE', 'PROCESSING') AND receiving_account_id IS NOT NULL;"); } catch {}
76
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_payment_sessions_order ON payment_sessions (merchant_id, order_id, created_at);"); } catch {}
77
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_retry ON webhook_deliveries (is_successful, next_retry_at);"); } catch {}
78
+ try { db.exec("ALTER TABLE payment_receiving_accounts ADD COLUMN is_merchant_enabled INTEGER NOT NULL DEFAULT 1;"); } catch {}
79
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_single_active_vpa_per_merchant ON payment_receiving_accounts (merchant_id) WHERE is_active = 1;"); } catch {}
80
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_email ON customers (email);"); } catch {}
81
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_mobile ON customers (mobile_number);"); } catch {}
82
+ try { db.exec("ALTER TABLE customers ADD COLUMN ban_reason TEXT;"); } catch {}
83
+ try { db.exec("ALTER TABLE customers ADD COLUMN banned_at TEXT;"); } catch {}
84
+ try { db.exec("ALTER TABLE customers ADD COLUMN unbanned_at TEXT;"); } catch {}
85
+ try { db.exec("ALTER TABLE customers ADD COLUMN last_device TEXT;"); } catch {}
86
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_customer_sessions_token ON customer_sessions (token, is_active);"); } catch {}
87
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_customer_sessions_customer ON customer_sessions (customer_id);"); } catch {}
88
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_security_events_customer ON security_events (customer_id);"); } catch {}
89
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_security_events_session ON security_events (payment_session_id);"); } catch {}
90
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_security_events_type ON security_events (event_type);"); } catch {}
91
+ try { db.exec("ALTER TABLE payment_sessions ADD COLUMN device_id TEXT REFERENCES devices(id);"); } catch {}
92
+ try { db.exec(`
93
+ CREATE TABLE IF NOT EXISTS devices (
94
+ id TEXT PRIMARY KEY,
95
+ customer_id TEXT REFERENCES customers(id) ON DELETE SET NULL,
96
+ public_key TEXT NOT NULL,
97
+ fingerprint_hash TEXT NOT NULL,
98
+ user_agent TEXT,
99
+ platform TEXT,
100
+ browser TEXT,
101
+ timezone TEXT,
102
+ language TEXT,
103
+ screen_signature TEXT,
104
+ device_token TEXT UNIQUE,
105
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
106
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
107
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
108
+ );
109
+ `); } catch {}
110
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_devices_customer ON devices (customer_id);"); } catch {}
111
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_devices_fingerprint ON devices (fingerprint_hash);"); } catch {}
112
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_payment_sessions_device ON payment_sessions (device_id);"); } catch {}
113
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_sessions_attributed_obs ON payment_sessions (attributed_observation_id) WHERE attributed_observation_id IS NOT NULL;"); } catch {}
114
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_observations_bank_ref ON payment_observations (bank_reference_number);"); } catch {}
115
+ try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_unique_attributed_bank_ref ON payment_observations (bank_reference_number) WHERE bank_reference_number IS NOT NULL AND attributed_payment_id IS NOT NULL;"); } catch {}
116
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_observations_fingerprint ON payment_observations (fingerprint_hash);"); } catch {}
117
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_observations_attributed_payment ON payment_observations (attributed_payment_id);"); } catch {}
118
+
119
+ // Seed default merchant if empty
120
+ const merchantRepo = new MerchantRepository(db);
121
+ const existing = merchantRepo.findAll();
122
+
123
+ let defaultMerchant;
124
+ if (existing.length === 0) {
125
+ defaultMerchant = merchantRepo.create({
126
+ id: '00000000-0000-0000-0000-000000000001',
127
+ businessName: 'ELPAY Demo Store',
128
+ slug: 'demo-store',
129
+ contactEmail: 'merchant@elpay.local',
130
+ contactPhone: '9876543210',
131
+ webhookUrl: process.env.COIN_STORE_WEBHOOK_URL || 'http://localhost:5000/api/webhook',
132
+ webhookSecret: getCoinStoreWebhookSecret(),
133
+ branding: {
134
+ appName: 'ELPAY PG',
135
+ primaryColor: '#2563eb',
136
+ logoUrl: null
137
+ }
138
+ });
139
+ } else {
140
+ defaultMerchant = existing[0];
141
+ if (!defaultMerchant.webhookSecret) {
142
+ merchantRepo.update(defaultMerchant.id, {
143
+ webhookUrl: 'http://localhost:5000/api/webhook',
144
+ webhookSecret: getCoinStoreWebhookSecret()
145
+ });
146
+ } else if (defaultMerchant.webhookSecret === LEGACY_DEFAULT_WEBHOOK_SECRET) {
147
+ // Older installs were seeded with a publicly known secret: rotate it.
148
+ merchantRepo.update(defaultMerchant.id, { webhookSecret: getCoinStoreWebhookSecret() });
149
+ console.warn('[migrate] Rotated the default merchant webhook secret away from the legacy public value. Update your merchant webhook verifier.');
150
+ }
151
+ }
152
+
153
+ // Seed default UPI receiving account if none
154
+ const accountRepo = new ReceivingAccountRepository(db);
155
+ const accounts = accountRepo.findByMerchantId(defaultMerchant.id);
156
+ if (accounts.length === 0) {
157
+ accountRepo.create({
158
+ id: '00000000-0000-0000-0000-000000000002',
159
+ merchantId: defaultMerchant.id,
160
+ upiId: 'merchant@upi',
161
+ payeeName: defaultMerchant.businessName,
162
+ isActive: true
163
+ });
164
+ }
165
+
166
+ // Demo customer seeding is opt-in and never carries real personal data.
167
+ // Previously this unconditionally created a customer with a real name, phone number and
168
+ // email, and flagged the phone as verified — shipping someone's PII (and a pre-authorised
169
+ // OTP login identity) into every merchant deployment.
170
+ if (process.env.ELPAY_SEED_DEMO_CUSTOMER === 'true') {
171
+ const customerRepo = new CustomerRepository(db);
172
+ const demoMobile = '9000000000';
173
+ if (!customerRepo.findByMobile(demoMobile)) {
174
+ customerRepo.create({
175
+ id: '00000000-0000-0000-0000-000000000002',
176
+ name: 'Demo Customer',
177
+ email: 'demo-customer@elpay.local',
178
+ mobileNumber: demoMobile,
179
+ isPhoneVerified: false
180
+ });
181
+ }
182
+ }
183
+
184
+ return {
185
+ db,
186
+ defaultMerchant
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Completely wipes all customer identity records and active sessions,
192
+ * reinitializing the customer identity store to a pristine state.
193
+ * @param {import('./database.js').Database} [targetDb]
194
+ */
195
+ export function reinitializeCustomerIdentity(targetDb) {
196
+ const db = targetDb || getDatabase();
197
+ try { db.exec("PRAGMA foreign_keys = OFF;"); } catch {}
198
+ try { db.exec("UPDATE payment_sessions SET customer_id = NULL WHERE customer_id IS NOT NULL;"); } catch {}
199
+ try { db.exec("DELETE FROM customer_sessions;"); } catch {}
200
+ try { db.exec("DELETE FROM customers;"); } catch {}
201
+ try { db.exec("PRAGMA foreign_keys = ON;"); } catch {}
202
+ return { success: true, message: 'All old customer identity records wiped and reinitialized.' };
203
+ }
204
+
205
+ // Auto-run if executed directly as script
206
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
207
+ console.log('Running ELPAY PG database migrations...');
208
+ const result = runMigrations();
209
+ console.log(`Migration completed successfully. Default merchant: ${result.defaultMerchant.businessName} (${result.defaultMerchant.id})`);
210
+ }
@@ -0,0 +1,68 @@
1
+ import crypto from 'node:crypto';
2
+ import { AuditActorType } from '../../common/index.js';
3
+
4
+ export class AuditLogRepository {
5
+ /**
6
+ * @param {import('../database.js').Database} db
7
+ */
8
+ constructor(db) {
9
+ this.db = db;
10
+ }
11
+
12
+ log(data) {
13
+ const id = data.id || crypto.randomUUID();
14
+ const now = new Date().toISOString();
15
+ const details = typeof data.details === 'object' ? JSON.stringify(data.details) : (data.details || '{}');
16
+
17
+ const sql = `
18
+ INSERT INTO audit_logs (id, merchant_id, entity_type, entity_id, action, actor_type, actor_id, details, ip_address, created_at)
19
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
20
+ `;
21
+
22
+ this.db.execute(sql, [
23
+ id,
24
+ data.merchantId || null,
25
+ data.entityType,
26
+ data.entityId,
27
+ data.action,
28
+ data.actorType || AuditActorType.SYSTEM,
29
+ data.actorId || null,
30
+ details,
31
+ data.ipAddress || null,
32
+ now
33
+ ]);
34
+
35
+ return id;
36
+ }
37
+
38
+ findByEntity(entityType, entityId) {
39
+ const rows = this.db.query(
40
+ 'SELECT * FROM audit_logs WHERE entity_type = ? AND entity_id = ? ORDER BY created_at ASC',
41
+ [entityType, entityId]
42
+ );
43
+ return rows.map(r => this.mapRow(r));
44
+ }
45
+
46
+ findByMerchant(merchantId, limit = 100) {
47
+ const rows = this.db.query(
48
+ 'SELECT * FROM audit_logs WHERE merchant_id = ? ORDER BY created_at DESC LIMIT ?',
49
+ [merchantId, limit]
50
+ );
51
+ return rows.map(r => this.mapRow(r));
52
+ }
53
+
54
+ mapRow(row) {
55
+ return {
56
+ id: row.id,
57
+ merchantId: row.merchant_id,
58
+ entityType: row.entity_type,
59
+ entityId: row.entity_id,
60
+ action: row.action,
61
+ actorType: row.actor_type,
62
+ actorId: row.actor_id,
63
+ details: JSON.parse(row.details || '{}'),
64
+ ipAddress: row.ip_address,
65
+ createdAt: new Date(row.created_at)
66
+ };
67
+ }
68
+ }
@@ -0,0 +1,181 @@
1
+ import crypto from 'node:crypto';
2
+ import { blindIndex } from '../../common/index.js';
3
+
4
+ /** Namespace so session-token and device-token digests can never collide. */
5
+ const INDEX_NS = 'csess';
6
+
7
+ export class CustomerSessionRepository {
8
+ /**
9
+ * @param {import('../database.js').Database} db
10
+ * @param {import('./customer.repository.js').CustomerRepository} [customerRepo]
11
+ */
12
+ constructor(db, customerRepo = null) {
13
+ this.db = db;
14
+ this.customerRepo = customerRepo;
15
+ }
16
+
17
+ /**
18
+ * Creates an active server-authoritative session for an authenticated customer.
19
+ * @param {{
20
+ * customerId: string,
21
+ * ipAddress?: string,
22
+ * userAgent?: string,
23
+ * ttlHours?: number
24
+ * }} data
25
+ */
26
+ create(data) {
27
+ const id = crypto.randomUUID();
28
+ const token = `csess_${crypto.randomBytes(32).toString('hex')}`;
29
+ const ttlHours = data.ttlHours || 720; // 30 days default
30
+ const now = new Date();
31
+ const expiresAt = new Date(now.getTime() + ttlHours * 3600 * 1000);
32
+
33
+ const sql = `
34
+ INSERT INTO customer_sessions (id, customer_id, token, expires_at, is_active, ip_address, user_agent, created_at, updated_at)
35
+ VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)
36
+ `;
37
+
38
+ // The bearer token is returned to the client once and never read back from the database,
39
+ // so only a keyed blind index is stored. A database dump therefore cannot be replayed
40
+ // as valid sessions, which a plaintext (or plain-hashed) column would allow.
41
+ this.db.execute(sql, [
42
+ id,
43
+ data.customerId,
44
+ blindIndex(token, INDEX_NS),
45
+ expiresAt.toISOString(),
46
+ data.ipAddress || null,
47
+ data.userAgent || null,
48
+ now.toISOString(),
49
+ now.toISOString()
50
+ ]);
51
+
52
+ return {
53
+ id,
54
+ customerId: data.customerId,
55
+ token,
56
+ expiresAt,
57
+ isActive: true,
58
+ createdAt: now
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Validates a session token and retrieves associated customer profile.
64
+ * @param {string} token
65
+ * @returns {{ session: any, customer: any } | null}
66
+ */
67
+ findByToken(token) {
68
+ if (!token || typeof token !== 'string') return null;
69
+
70
+ const row = this.db.queryOne(
71
+ 'SELECT * FROM customer_sessions WHERE token = ? AND is_active = 1',
72
+ [blindIndex(token, INDEX_NS)]
73
+ );
74
+
75
+ // Rows written before index-only storage held the raw token.
76
+ if (!row && typeof token === 'string' && token.startsWith('csess_') && token.length < 100) {
77
+ const legacy = this.db.queryOne(
78
+ 'SELECT * FROM customer_sessions WHERE token = ? AND is_active = 1',
79
+ [token]
80
+ );
81
+ if (legacy) {
82
+ // Re-index on first successful use so the plaintext row does not linger.
83
+ try {
84
+ this.db.execute('UPDATE customer_sessions SET token = ? WHERE id = ?', [blindIndex(token, INDEX_NS), legacy.id]);
85
+ } catch {}
86
+ return this.findByToken(token);
87
+ }
88
+ }
89
+
90
+ if (!row) return null;
91
+
92
+ const expiresAt = new Date(row.expires_at);
93
+ if (expiresAt.getTime() <= Date.now()) {
94
+ // Mark expired
95
+ try {
96
+ this.db.execute('UPDATE customer_sessions SET is_active = 0, updated_at = ? WHERE id = ?', [
97
+ new Date().toISOString(),
98
+ row.id
99
+ ]);
100
+ } catch {}
101
+ return null;
102
+ }
103
+
104
+ const session = {
105
+ id: row.id,
106
+ customerId: row.customer_id,
107
+ expiresAt,
108
+ isActive: Boolean(row.is_active),
109
+ createdAt: new Date(row.created_at)
110
+ };
111
+
112
+ let customer = null;
113
+ if (this.customerRepo) {
114
+ customer = this.customerRepo.findById(row.customer_id);
115
+ } else {
116
+ const custRow = this.db.queryOne('SELECT * FROM customers WHERE id = ?', [row.customer_id]);
117
+ if (custRow) {
118
+ customer = {
119
+ id: custRow.id,
120
+ name: custRow.name,
121
+ email: custRow.email,
122
+ mobileNumber: custRow.mobile_number,
123
+ status: custRow.status,
124
+ createdAt: new Date(custRow.created_at),
125
+ updatedAt: new Date(custRow.updated_at)
126
+ };
127
+ }
128
+ }
129
+
130
+ if (!customer || customer.status !== 'ACTIVE') {
131
+ return null;
132
+ }
133
+
134
+ return { session, customer };
135
+ }
136
+
137
+ /**
138
+ * Revokes a session token (logout).
139
+ * @param {string} token
140
+ * @returns {boolean}
141
+ */
142
+ revokeToken(token) {
143
+ if (!token) return false;
144
+ const now = new Date().toISOString();
145
+ this.db.execute(
146
+ 'UPDATE customer_sessions SET is_active = 0, updated_at = ? WHERE token = ?',
147
+ [now, blindIndex(token, INDEX_NS)]
148
+ );
149
+ // Also clear any legacy plaintext row so logout always revokes.
150
+ this.db.execute(
151
+ 'UPDATE customer_sessions SET is_active = 0, updated_at = ? WHERE token = ?',
152
+ [now, token]
153
+ );
154
+ return true;
155
+ }
156
+
157
+ /**
158
+ * Revokes all active sessions for a customer.
159
+ * @param {string} customerId
160
+ */
161
+ revokeAllForCustomer(customerId) {
162
+ if (!customerId) return;
163
+ const now = new Date().toISOString();
164
+ this.db.execute(
165
+ 'UPDATE customer_sessions SET is_active = 0, updated_at = ? WHERE customer_id = ?',
166
+ [now, customerId]
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Returns count of currently active sessions.
172
+ * @returns {number}
173
+ */
174
+ countActive() {
175
+ const row = this.db.queryOne(
176
+ 'SELECT COUNT(*) as c FROM customer_sessions WHERE is_active = 1 AND expires_at > ?',
177
+ [new Date().toISOString()]
178
+ );
179
+ return row?.c || 0;
180
+ }
181
+ }