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.
- package/.env.example +8 -0
- package/README.md +176 -2
- package/package.json +21 -4
- package/public/elpay-checkout.js +769 -0
- package/public/elpay-sms-gateway.apk +0 -0
- package/public/elpay-sms-gateway.apk.sha256 +1 -0
- package/public/elpay.js +785 -0
- package/public/fonts/dm-sans.woff2 +0 -0
- package/public/fonts/sora.woff2 +0 -0
- package/public/views/checkout.html +2839 -0
- package/public/views/partials/auth.html +110 -0
- package/public/views/partials/footer.html +54 -0
- package/public/views/partials/header.html +101 -0
- package/public/views/partials/modals.html +179 -0
- package/public/views/partials/payment-methods.html +526 -0
- package/public/views/partials/sidebar.html +75 -0
- package/public/views/partials/splash.html +35 -0
- package/src/__tests__/smoke.test.js +8 -0
- package/src/auth-guard.js +95 -0
- package/src/index.js +137 -0
- package/src/router.js +46 -0
- package/src/routes/admin.routes.js +2421 -0
- package/src/routes/checkout.routes.js +2000 -0
- package/src/routes/customer.routes.js +650 -0
- package/src/routes/device.routes.js +303 -0
- package/src/routes/payments.routes.js +446 -0
- package/src/routes/pollers.routes.js +653 -0
- package/src/services/observer-poller.service.js +369 -0
- package/src/services/tunnel-manager.js +130 -0
- package/src/services/whatsapp-berry.service.js +743 -0
- package/src/vendor/common/__tests__/common.test.js +297 -0
- package/src/vendor/common/__tests__/common.test.ts +160 -0
- package/src/vendor/common/contracts.js +64 -0
- package/src/vendor/common/contracts.ts +188 -0
- package/src/vendor/common/crypto.js +132 -0
- package/src/vendor/common/crypto.ts +112 -0
- package/src/vendor/common/enums.js +91 -0
- package/src/vendor/common/enums.ts +91 -0
- package/src/vendor/common/index.js +12 -0
- package/src/vendor/common/index.ts +8 -0
- package/src/vendor/common/money.js +62 -0
- package/src/vendor/common/money.ts +56 -0
- package/src/vendor/common/observation.js +27 -0
- package/src/vendor/common/observation.ts +52 -0
- package/src/vendor/common/runtime-config.js +246 -0
- package/src/vendor/common/secrets.js +374 -0
- package/src/vendor/common/state-machine.d.ts +1 -0
- package/src/vendor/common/state-machine.js +1 -0
- package/src/vendor/common/state-transitions.js +85 -0
- package/src/vendor/common/tunnel-command.js +59 -0
- package/src/vendor/common/upi-providers.js +389 -0
- package/src/vendor/common/upi-providers.ts +400 -0
- package/src/vendor/common/upi.js +69 -0
- package/src/vendor/common/upi.ts +61 -0
- package/src/vendor/core/__tests__/core.test.js +683 -0
- package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
- package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
- package/src/vendor/core/attribution-engine.js +855 -0
- package/src/vendor/core/bank-email-parser.js +105 -0
- package/src/vendor/core/bank-sms-parser.js +183 -0
- package/src/vendor/core/device-security.js +506 -0
- package/src/vendor/core/index.js +13 -0
- package/src/vendor/core/normalizer.js +165 -0
- package/src/vendor/core/qr-lib.js +8 -0
- package/src/vendor/core/qr.js +62 -0
- package/src/vendor/core/queue-engine.js +153 -0
- package/src/vendor/core/recovery-engine.js +159 -0
- package/src/vendor/core/state-machine.js +42 -0
- package/src/vendor/core/timing-engine.js +272 -0
- package/src/vendor/core/verification-pipeline.js +1164 -0
- package/src/vendor/core/webhook-engine.js +280 -0
- package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
- package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
- package/src/vendor/db/__tests__/db.test.js +265 -0
- package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
- package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
- package/src/vendor/db/database.js +181 -0
- package/src/vendor/db/index.js +12 -0
- package/src/vendor/db/migrate.js +210 -0
- package/src/vendor/db/repositories/audit-log.repository.js +68 -0
- package/src/vendor/db/repositories/customer-session.repository.js +181 -0
- package/src/vendor/db/repositories/customer.repository.js +636 -0
- package/src/vendor/db/repositories/device.repository.js +285 -0
- package/src/vendor/db/repositories/merchant.repository.js +158 -0
- package/src/vendor/db/repositories/observation.repository.js +124 -0
- package/src/vendor/db/repositories/payment-session.repository.js +618 -0
- package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
- package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
- package/src/vendor/db/schema.sql +266 -0
- package/src/vendor/db/sqlite-schema.sql +270 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { encrypt, decrypt } from './crypto.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
// Resolved from this file, not cwd: every service must read the same files.
|
|
9
|
+
const SECRETS_DIR = path.resolve(__dirname, '../../../data/.secrets');
|
|
10
|
+
|
|
11
|
+
const cache = new Map();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves a service secret: explicit env var first, else a random value
|
|
15
|
+
* generated once and persisted under data/.secrets/ (shared by all local services).
|
|
16
|
+
* @param {string} name file-safe secret name
|
|
17
|
+
* @param {string} [envVar] env var that overrides the file
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function resolveSecret(name, envVar) {
|
|
21
|
+
const fromEnv = envVar ? (process.env[envVar] || '').trim() : '';
|
|
22
|
+
if (fromEnv) return fromEnv;
|
|
23
|
+
if (cache.has(name)) return cache.get(name);
|
|
24
|
+
|
|
25
|
+
const file = path.join(SECRETS_DIR, name);
|
|
26
|
+
let value = '';
|
|
27
|
+
try { value = fs.readFileSync(file, 'utf8').trim(); } catch {}
|
|
28
|
+
if (!value) {
|
|
29
|
+
fs.mkdirSync(SECRETS_DIR, { recursive: true });
|
|
30
|
+
const fresh = crypto.randomBytes(32).toString('hex');
|
|
31
|
+
try {
|
|
32
|
+
fs.writeFileSync(file, fresh, { flag: 'wx', mode: 0o600 });
|
|
33
|
+
value = fresh;
|
|
34
|
+
} catch {
|
|
35
|
+
// Another process created it first: use theirs.
|
|
36
|
+
value = fs.readFileSync(file, 'utf8').trim();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
cache.set(name, value);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Constant-time string comparison (length-independent via hashing). */
|
|
44
|
+
export function safeEqual(a, b) {
|
|
45
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
46
|
+
const ha = crypto.createHash('sha256').update(a).digest();
|
|
47
|
+
const hb = crypto.createHash('sha256').update(b).digest();
|
|
48
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const getAdminToken = () => resolveSecret('admin_api_token', 'ADMIN_API_TOKEN');
|
|
52
|
+
export const getObserverToken = () => resolveSecret('observer_api_token', 'OBSERVER_API_TOKEN');
|
|
53
|
+
export const getCoinStoreWebhookSecret = () => resolveSecret('coin_store_webhook_secret', 'COIN_STORE_WEBHOOK_SECRET');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Shared credential proving a request originated from a co-located service (server-side render
|
|
57
|
+
* proxies) rather than a browser. A plain `X-Internal-Render: true` header is attacker-settable,
|
|
58
|
+
* so the header is only honoured together with this secret.
|
|
59
|
+
*/
|
|
60
|
+
export const getInternalServiceToken = () => resolveSecret('internal_service_token', 'INTERNAL_SERVICE_TOKEN');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Key used to sign device credentials and device challenges.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately separate from the internal service token: that token is transmitted by the
|
|
66
|
+
* checkout portal on every internal render, so sharing a key with it would mean a leaked
|
|
67
|
+
* internal token could be used to mint a valid device credential for any device id.
|
|
68
|
+
*/
|
|
69
|
+
export const getDeviceCredentialKey = () => resolveSecret('device_credential_key', 'DEVICE_CREDENTIAL_KEY');
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Key used to index high-entropy secrets for lookup (session tokens, device tokens).
|
|
73
|
+
* Separate from the internal service token so a leaked internal token cannot be used to
|
|
74
|
+
* compute stored indexes.
|
|
75
|
+
*/
|
|
76
|
+
export const getBlindIndexKey = () => resolveSecret('blind_index_key', 'BLIND_INDEX_KEY');
|
|
77
|
+
|
|
78
|
+
/** True when a socket remoteAddress is loopback (IPv4 or IPv6). */
|
|
79
|
+
function isLoopbackRemoteAddress(remoteAddress) {
|
|
80
|
+
// Fail closed: an absent or malformed address is NOT loopback. Returning true here would
|
|
81
|
+
// let any request that hides its peer address satisfy the internal-service check.
|
|
82
|
+
if (!remoteAddress || typeof remoteAddress !== 'string') return false;
|
|
83
|
+
const addr = remoteAddress.trim().toLowerCase();
|
|
84
|
+
if (!addr) return false;
|
|
85
|
+
if (addr === '::1' || addr === '::ffff:127.0.0.1') return true;
|
|
86
|
+
// 127.0.0.0/8
|
|
87
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(addr);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True only when the caller proves it is a trusted co-located internal service. */
|
|
91
|
+
export function isInternalServiceRequest(req) {
|
|
92
|
+
if (!isLoopbackRemoteAddress(req?.socket?.remoteAddress)) return false;
|
|
93
|
+
const match = /^Bearer\s+(.+)$/i.exec(req?.headers?.['authorization'] || '');
|
|
94
|
+
const presented = (match ? match[1].trim() : '') || (req?.headers?.['x-internal-token'] || '');
|
|
95
|
+
return Boolean(presented) && safeEqual(presented, getInternalServiceToken());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mints a server-signed device credential bound to a specific `deviceId`.
|
|
100
|
+
* Format: `devtok_<deviceId>.<nonce>.<issuedAt>.<hmac>`
|
|
101
|
+
* @param {string} deviceId
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function issueDeviceCredential(deviceId) {
|
|
105
|
+
if (!deviceId || typeof deviceId !== 'string') {
|
|
106
|
+
throw new Error('deviceId is required to issue a device credential');
|
|
107
|
+
}
|
|
108
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
109
|
+
const issuedAt = String(Date.now());
|
|
110
|
+
const mac = crypto
|
|
111
|
+
.createHmac('sha256', getDeviceCredentialKey())
|
|
112
|
+
.update(`devcred:v1:${deviceId}:${nonce}:${issuedAt}`)
|
|
113
|
+
.digest('hex');
|
|
114
|
+
return `devtok_${deviceId}.${nonce}.${issuedAt}.${mac}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Verifies in constant time that a presented `deviceToken` is a genuine server-signed
|
|
119
|
+
* credential issued for `deviceId`.
|
|
120
|
+
* @param {string} deviceId
|
|
121
|
+
* @param {string} token
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function verifyDeviceCredential(deviceId, token) {
|
|
125
|
+
if (!deviceId || typeof deviceId !== 'string' || !token || typeof token !== 'string') {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const prefix = `devtok_${deviceId}.`;
|
|
129
|
+
if (!token.startsWith(prefix)) return false;
|
|
130
|
+
const rest = token.slice(prefix.length);
|
|
131
|
+
const parts = rest.split('.');
|
|
132
|
+
if (parts.length !== 3) return false;
|
|
133
|
+
const [nonce, issuedAt, presentedMac] = parts;
|
|
134
|
+
if (!nonce || !issuedAt || !presentedMac) return false;
|
|
135
|
+
const expectedMac = crypto
|
|
136
|
+
.createHmac('sha256', getDeviceCredentialKey())
|
|
137
|
+
.update(`devcred:v1:${deviceId}:${nonce}:${issuedAt}`)
|
|
138
|
+
.digest('hex');
|
|
139
|
+
return safeEqual(presentedMac, expectedMac);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const DEVICE_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
143
|
+
const consumedDeviceChallenges = new Map();
|
|
144
|
+
|
|
145
|
+
function deviceChallengeMac(deviceId, nonce, expiresAt) {
|
|
146
|
+
return crypto
|
|
147
|
+
.createHmac('sha256', getDeviceCredentialKey())
|
|
148
|
+
.update(`devchal:v1:${deviceId}:${nonce}:${expiresAt}`)
|
|
149
|
+
.digest('hex');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Mints a short-lived, single-use challenge bound to a registered `deviceId` for
|
|
154
|
+
* challenge-response token re-issuance.
|
|
155
|
+
* @param {string} deviceId
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
export function createDeviceChallenge(deviceId) {
|
|
159
|
+
if (!deviceId || typeof deviceId !== 'string') {
|
|
160
|
+
throw new Error('deviceId is required to create a device challenge');
|
|
161
|
+
}
|
|
162
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
163
|
+
const expiresAt = String(Date.now() + DEVICE_CHALLENGE_TTL_MS);
|
|
164
|
+
const mac = deviceChallengeMac(deviceId, nonce, expiresAt);
|
|
165
|
+
return `devchal_${deviceId}.${nonce}.${expiresAt}.${mac}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Verifies and consumes a single-use device challenge bound to `deviceId`.
|
|
170
|
+
* @param {string} challenge
|
|
171
|
+
* @param {string} deviceId
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
export function consumeDeviceChallenge(challenge, deviceId) {
|
|
175
|
+
if (typeof challenge !== 'string' || typeof deviceId !== 'string' || !deviceId) return false;
|
|
176
|
+
const prefix = `devchal_${deviceId}.`;
|
|
177
|
+
if (!challenge.startsWith(prefix)) return false;
|
|
178
|
+
const parts = challenge.slice(prefix.length).split('.');
|
|
179
|
+
if (parts.length !== 3) return false;
|
|
180
|
+
const [nonce, expiresAt, mac] = parts;
|
|
181
|
+
if (!nonce || !expiresAt || !mac) return false;
|
|
182
|
+
if (!safeEqual(mac, deviceChallengeMac(deviceId, nonce, expiresAt))) return false;
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (!(Number(expiresAt) > now)) return false;
|
|
185
|
+
for (const [k, exp] of consumedDeviceChallenges) {
|
|
186
|
+
if (exp <= now) consumedDeviceChallenges.delete(k);
|
|
187
|
+
}
|
|
188
|
+
const key = `${deviceId}:${nonce}`;
|
|
189
|
+
if (consumedDeviceChallenges.has(key)) return false;
|
|
190
|
+
consumedDeviceChallenges.set(key, Number(expiresAt));
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Literal that older builds seeded into databases; must never be trusted. */
|
|
195
|
+
export const LEGACY_DEFAULT_WEBHOOK_SECRET = 'merchant_webhook_secret_for_wallet_store';
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Master key for encrypting data at rest (merchant webhook secrets, OTPs).
|
|
199
|
+
* Generated once and persisted alongside the other local secrets.
|
|
200
|
+
*/
|
|
201
|
+
export const getEncryptionMasterKey = () => resolveSecret('encryption_master_key', 'ENCRYPTION_MASTER_KEY');
|
|
202
|
+
|
|
203
|
+
/** Envelope prefix marking a stored value as AES-256-GCM ciphertext. */
|
|
204
|
+
const ENC_PREFIX = 'enc:v1:';
|
|
205
|
+
|
|
206
|
+
/** True when a stored value is in the ciphertext envelope. */
|
|
207
|
+
export function isEncryptedValue(value) {
|
|
208
|
+
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Encrypts a value for storage. Values are idempotent: an already-encrypted value is
|
|
213
|
+
* returned unchanged so callers can wrap reads and writes without tracking state.
|
|
214
|
+
*/
|
|
215
|
+
export function encryptAtRest(plaintext) {
|
|
216
|
+
if (plaintext === null || plaintext === undefined || plaintext === '') return plaintext;
|
|
217
|
+
if (isEncryptedValue(plaintext)) return plaintext;
|
|
218
|
+
return ENC_PREFIX + encrypt(String(plaintext), getEncryptionMasterKey());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Decrypts a stored value, transparently passing through values written before encryption
|
|
223
|
+
* existed (plaintext) so existing databases keep working and get re-encrypted on next write.
|
|
224
|
+
*/
|
|
225
|
+
export function decryptAtRest(stored) {
|
|
226
|
+
if (!isEncryptedValue(stored)) return stored;
|
|
227
|
+
try {
|
|
228
|
+
return decrypt(stored.slice(ENC_PREFIX.length), getEncryptionMasterKey());
|
|
229
|
+
} catch {
|
|
230
|
+
// A value encrypted under a different/lost key must not silently become plaintext.
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Keyed digest for high-entropy secrets that are only ever looked up, never read back
|
|
237
|
+
* (session tokens, device tokens). Unlike a plain hash this is not brute-forceable from a
|
|
238
|
+
* database dump, because the key lives outside the database.
|
|
239
|
+
*
|
|
240
|
+
* The key is dedicated to this purpose rather than shared with the internal service token:
|
|
241
|
+
* that token travels in a request header on every internal render, so a shared key would
|
|
242
|
+
* leak the indexing secret wherever those headers are logged.
|
|
243
|
+
*/
|
|
244
|
+
export function blindIndex(value, namespace = 'token') {
|
|
245
|
+
if (value === null || value === undefined || value === '') return null;
|
|
246
|
+
const digest = crypto.createHmac('sha256', getBlindIndexKey())
|
|
247
|
+
.update(`${namespace}:${value}`)
|
|
248
|
+
.digest('hex');
|
|
249
|
+
return `${namespace}_v1_${digest}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Compares a presented secret against a stored blind index in constant time.
|
|
254
|
+
*
|
|
255
|
+
* Rows written before the key was separated were indexed with the internal service token,
|
|
256
|
+
* so that key is still accepted for existing rows; the row is re-indexed on first use.
|
|
257
|
+
*/
|
|
258
|
+
export function blindIndexMatches(value, storedIndex, namespace = 'token') {
|
|
259
|
+
if (!value || !storedIndex) return false;
|
|
260
|
+
const candidate = blindIndex(value, namespace);
|
|
261
|
+
if (candidate && safeEqual(candidate, storedIndex)) return true;
|
|
262
|
+
// Legacy index produced with the previous key.
|
|
263
|
+
const legacy = crypto.createHmac('sha256', getInternalServiceToken())
|
|
264
|
+
.update(`${namespace}:${value}`)
|
|
265
|
+
.digest('hex');
|
|
266
|
+
return safeEqual(`${namespace}_v1_${legacy}`, storedIndex);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** True when the request's `Authorization: Bearer` value matches any of the given tokens. */
|
|
270
|
+
export function hasBearer(req, ...tokens) {
|
|
271
|
+
const match = /^Bearer\s+(.+)$/i.exec(req?.headers?.['authorization'] || '');
|
|
272
|
+
if (!match) return false;
|
|
273
|
+
const presented = match[1].trim();
|
|
274
|
+
return tokens.some(t => safeEqual(presented, t));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const BLOCKED_HOSTNAMES = new Set(['localhost', 'localhost.localdomain', 'ip6-localhost', 'ip6-loopback']);
|
|
278
|
+
|
|
279
|
+
/** True for loopback, RFC1918, link-local, CGNAT, multicast and reserved IPv4/IPv6 ranges. */
|
|
280
|
+
function isPrivateAddress(hostname) {
|
|
281
|
+
const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
282
|
+
if (lower === '::1' || lower === '::' || lower === '0.0.0.0') return true;
|
|
283
|
+
if (lower.endsWith('.local') || lower.endsWith('.internal') || lower.endsWith('.localhost')) return true;
|
|
284
|
+
|
|
285
|
+
const v4 = lower.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
286
|
+
if (!v4) {
|
|
287
|
+
// Unique-local (fc00::/7) and link-local (fe80::/10) IPv6.
|
|
288
|
+
return /^f[cd][0-9a-f]{2}:/.test(lower) || /^fe[89ab][0-9a-f]:/.test(lower);
|
|
289
|
+
}
|
|
290
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
291
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
292
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
293
|
+
if (a === 192 && b === 168) return true;
|
|
294
|
+
if (a === 169 && b === 254) return true;
|
|
295
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
296
|
+
if (a >= 224) return true;
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Self-hosted merchants legitimately deliver webhooks to a host on their own LAN or loopback,
|
|
302
|
+
* so those are permitted only when explicitly opted in. Without the opt-in, private, loopback
|
|
303
|
+
* and link-local targets (including the cloud metadata endpoint) are refused.
|
|
304
|
+
*/
|
|
305
|
+
function privateWebhooksAllowed() {
|
|
306
|
+
return process.env.ELPAY_ALLOW_PRIVATE_WEBHOOKS === 'true';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Validates an operator-supplied outbound webhook URL.
|
|
311
|
+
* Rejects non-HTTP(S) schemes, embedded credentials and — unless explicitly opted in —
|
|
312
|
+
* loopback/private/link-local hosts, so the gateway cannot be pointed at cloud metadata
|
|
313
|
+
* endpoints or its own internal admin/control-plane services (SSRF).
|
|
314
|
+
* @param {string} value
|
|
315
|
+
* @returns {{ valid: boolean, error?: string, url?: URL }}
|
|
316
|
+
*/
|
|
317
|
+
export function validateOutboundWebhookUrl(value) {
|
|
318
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
319
|
+
if (!raw) return { valid: false, error: 'Webhook URL is required.' };
|
|
320
|
+
if (raw.length > 2048) return { valid: false, error: 'Webhook URL is too long.' };
|
|
321
|
+
|
|
322
|
+
let url;
|
|
323
|
+
try {
|
|
324
|
+
url = new URL(raw);
|
|
325
|
+
} catch {
|
|
326
|
+
return { valid: false, error: 'Webhook URL must be a valid absolute URL.' };
|
|
327
|
+
}
|
|
328
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
329
|
+
return { valid: false, error: 'Webhook URL must use http:// or https://' };
|
|
330
|
+
}
|
|
331
|
+
if (url.username || url.password) {
|
|
332
|
+
return { valid: false, error: 'Webhook URL must not embed credentials.' };
|
|
333
|
+
}
|
|
334
|
+
const hostname = (url.hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
335
|
+
if (!hostname) return { valid: false, error: 'Webhook URL must include a hostname.' };
|
|
336
|
+
if (!privateWebhooksAllowed() && (BLOCKED_HOSTNAMES.has(hostname) || isPrivateAddress(hostname))) {
|
|
337
|
+
return {
|
|
338
|
+
valid: false,
|
|
339
|
+
error: 'Webhook URL must not point to a loopback or private network address (set ELPAY_ALLOW_PRIVATE_WEBHOOKS=true for self-hosted LAN targets).'
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { valid: true, url };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
|
346
|
+
const consumedOAuthStates = new Map();
|
|
347
|
+
|
|
348
|
+
function oauthStateMac(payload) {
|
|
349
|
+
return crypto.createHmac('sha256', getObserverToken()).update(`oauth-state:${payload}`).digest('hex');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Creates a signed, expiring OAuth `state` value. Mint it only from an authenticated admin route:
|
|
354
|
+
* the callback accepts nothing that was not minted there, which blocks login-CSRF account linking.
|
|
355
|
+
*/
|
|
356
|
+
export function createOAuthState() {
|
|
357
|
+
const payload = `${crypto.randomBytes(16).toString('hex')}.${Date.now() + OAUTH_STATE_TTL_MS}`;
|
|
358
|
+
return `${payload}.${oauthStateMac(payload)}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Verifies signature and expiry, and accepts each state only once (per process). */
|
|
362
|
+
export function consumeOAuthState(state) {
|
|
363
|
+
if (typeof state !== 'string') return false;
|
|
364
|
+
const parts = state.split('.');
|
|
365
|
+
if (parts.length !== 3) return false;
|
|
366
|
+
const [nonce, expiresAt, mac] = parts;
|
|
367
|
+
if (!safeEqual(mac, oauthStateMac(`${nonce}.${expiresAt}`))) return false;
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
if (!(Number(expiresAt) > now)) return false;
|
|
370
|
+
for (const [key, exp] of consumedOAuthStates) if (exp <= now) consumedOAuthStates.delete(key);
|
|
371
|
+
if (consumedOAuthStates.has(nonce)) return false;
|
|
372
|
+
consumedOAuthStates.set(nonce, Number(expiresAt));
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './state-transitions.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './state-transitions.js';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { PaymentStatus } from './enums.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Allowed payment session state transitions.
|
|
5
|
+
*
|
|
6
|
+
* This lives in `../common/index.js` rather than `../core/index.js` because the database layer
|
|
7
|
+
* (`../db/index.js`) must enforce it too, and `../db/index.js` cannot depend on `../core/index.js`
|
|
8
|
+
* (which already depends on it). `../core/index.js` re-exports these helpers unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export const VALID_TRANSITIONS = Object.freeze({
|
|
11
|
+
// EXPIRED is reachable from every non-terminal state: it is the universal "gave up"
|
|
12
|
+
// outcome (timer elapsed, or an administrator clearing the queue), so it must never be
|
|
13
|
+
// blocked just because the session had not reached the next stage yet.
|
|
14
|
+
[PaymentStatus.INITIATED]: [
|
|
15
|
+
PaymentStatus.ACTIVE,
|
|
16
|
+
PaymentStatus.QUEUED,
|
|
17
|
+
PaymentStatus.CANCELLED,
|
|
18
|
+
PaymentStatus.EXPIRED,
|
|
19
|
+
// COD: placing a Cash-on-Delivery order is a business success even before any UPI
|
|
20
|
+
// slot is acquired, so an order that was never activated still terminates as SUCCESS.
|
|
21
|
+
PaymentStatus.SUCCESS
|
|
22
|
+
],
|
|
23
|
+
[PaymentStatus.QUEUED]: [
|
|
24
|
+
PaymentStatus.ACTIVE,
|
|
25
|
+
PaymentStatus.CANCELLED,
|
|
26
|
+
PaymentStatus.EXPIRED,
|
|
27
|
+
// COD: a queued UPI order can be converted to a placed COD order.
|
|
28
|
+
PaymentStatus.SUCCESS
|
|
29
|
+
],
|
|
30
|
+
[PaymentStatus.ACTIVE]: [
|
|
31
|
+
PaymentStatus.PROCESSING,
|
|
32
|
+
// Tier-1 attribution settles a still-ACTIVE session as soon as the bank notification is
|
|
33
|
+
// attributed, without waiting for the customer to press "I have paid". A bank-confirmed
|
|
34
|
+
// credit is terminal regardless of whether the customer pressed the button.
|
|
35
|
+
PaymentStatus.SUCCESS,
|
|
36
|
+
PaymentStatus.FAILED,
|
|
37
|
+
PaymentStatus.EXPIRED,
|
|
38
|
+
PaymentStatus.CANCELLED
|
|
39
|
+
],
|
|
40
|
+
[PaymentStatus.PROCESSING]: [
|
|
41
|
+
PaymentStatus.SUCCESS,
|
|
42
|
+
PaymentStatus.FAILED,
|
|
43
|
+
PaymentStatus.EXPIRED
|
|
44
|
+
],
|
|
45
|
+
[PaymentStatus.SUCCESS]: [],
|
|
46
|
+
[PaymentStatus.FAILED]: [],
|
|
47
|
+
[PaymentStatus.EXPIRED]: [],
|
|
48
|
+
[PaymentStatus.CANCELLED]: []
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** States that can never change again. */
|
|
52
|
+
export const TERMINAL_STATES = Object.freeze([
|
|
53
|
+
PaymentStatus.SUCCESS,
|
|
54
|
+
PaymentStatus.FAILED,
|
|
55
|
+
PaymentStatus.EXPIRED,
|
|
56
|
+
PaymentStatus.CANCELLED
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** States that occupy a merchant's single UPI concurrency slot. */
|
|
60
|
+
export const SLOT_OCCUPYING_STATES = Object.freeze([
|
|
61
|
+
PaymentStatus.ACTIVE,
|
|
62
|
+
PaymentStatus.PROCESSING
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validates whether a state transition is permitted.
|
|
67
|
+
* @param {string} currentState
|
|
68
|
+
* @param {string} nextState
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function isValidTransition(currentState, nextState) {
|
|
72
|
+
const allowed = VALID_TRANSITIONS[currentState];
|
|
73
|
+
if (!allowed) return false;
|
|
74
|
+
return allowed.includes(nextState);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the state is a final, terminal outcome. */
|
|
78
|
+
export function isTerminalStatus(status) {
|
|
79
|
+
return TERMINAL_STATES.includes(status);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the state holds a merchant concurrency slot. */
|
|
83
|
+
export function holdsSlot(status) {
|
|
84
|
+
return SLOT_OCCUPYING_STATES.includes(status);
|
|
85
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
const TOKEN_RE = /^[A-Za-z0-9_\-=+/.]{20,}$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts the Cloudflare tunnel token from whatever the operator pasted
|
|
8
|
+
* (`cloudflared service install <token>`, `cloudflared tunnel run --token <token>`, or the bare token)
|
|
9
|
+
* and returns a fixed argv for `cloudflared`. Nothing else from the pasted text is ever executed.
|
|
10
|
+
* @param {string} rawCommand
|
|
11
|
+
* @returns {{ file: string, args: string[], redacted: string }}
|
|
12
|
+
*/
|
|
13
|
+
export function buildCloudflaredInvocation(rawCommand) {
|
|
14
|
+
const text = String(rawCommand || '').trim();
|
|
15
|
+
if (!text) throw new Error('Cloudflare Tunnel command cannot be empty.');
|
|
16
|
+
|
|
17
|
+
const explicit = text.match(/--token[\s=]+(\S+)/i) || text.match(/\binstall\s+(\S+)/i);
|
|
18
|
+
const candidates = explicit ? [explicit[1]] : text.split(/\s+/);
|
|
19
|
+
const token = candidates.map(c => c.replace(/^["']|["']$/g, '')).find(c => TOKEN_RE.test(c));
|
|
20
|
+
if (!token) {
|
|
21
|
+
throw new Error('No valid Cloudflare tunnel token found. Paste the command containing your tunnel token.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Keep an explicit path to the binary (e.g. "C:\Tools\cloudflared.exe") when it exists; otherwise rely on PATH.
|
|
25
|
+
const first = text.match(/^"([^"]+)"|^(\S+)/);
|
|
26
|
+
const candidate = first ? (first[1] || first[2]) : '';
|
|
27
|
+
const file = /cloudflared(\.exe)?$/i.test(candidate) && /[\\/]/.test(candidate) && fs.existsSync(candidate)
|
|
28
|
+
? candidate
|
|
29
|
+
: 'cloudflared';
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
file,
|
|
33
|
+
args: ['tunnel', '--no-autoupdate', 'run', '--token', token],
|
|
34
|
+
redacted: 'cloudflared tunnel --no-autoupdate run --token ***'
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Starts cloudflared for the pasted command without a shell. Never throws: a bad command or a
|
|
40
|
+
* missing binary is logged and boot continues without a tunnel.
|
|
41
|
+
* @param {string} rawCommand
|
|
42
|
+
* @param {import('node:child_process').SpawnOptions} [options]
|
|
43
|
+
* @returns {import('node:child_process').ChildProcess | null}
|
|
44
|
+
*/
|
|
45
|
+
export function startCloudflaredSafely(rawCommand, options = {}) {
|
|
46
|
+
let invocation;
|
|
47
|
+
try {
|
|
48
|
+
invocation = buildCloudflaredInvocation(rawCommand);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(`[Cloudflare Tunnel] Not started: ${err.message}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
console.log(` > ${invocation.redacted}`);
|
|
54
|
+
const child = spawn(invocation.file, invocation.args, { shell: false, env: { ...process.env }, ...options });
|
|
55
|
+
child.on('error', err => {
|
|
56
|
+
console.error(`[Cloudflare Tunnel] Could not run '${invocation.file}': ${err.message}. Install cloudflared or put it on PATH.`);
|
|
57
|
+
});
|
|
58
|
+
return child;
|
|
59
|
+
}
|