forgelayer-node 1.0.0 → 1.1.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/README.md +117 -4
- package/package.json +1 -1
- package/src/server.js +228 -218
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Node.js / Express middleware for accepting crypto payments via [ForgeLayer](https://forgelayer.io).
|
|
4
4
|
|
|
5
|
-
Drop `createCheckout()` into any Express app and get crypto payment endpoints in seconds — address generation, real-time rate conversion, payment polling,
|
|
5
|
+
Drop `createCheckout()` into any Express app and get crypto payment endpoints in seconds — address generation, real-time rate conversion, payment polling, webhook verification, and pluggable storage all included.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -72,12 +72,18 @@ createCheckout({
|
|
|
72
72
|
currency: 'USD', // fiat currency for price display
|
|
73
73
|
defaultChain: 'ethereum',
|
|
74
74
|
defaultToken: 'USDT',
|
|
75
|
-
paymentWindowMinutes: 30,
|
|
76
|
-
|
|
75
|
+
paymentWindowMinutes: 30, // browser countdown timer
|
|
76
|
+
gracePeriodMinutes: 0, // extra server-side window after expiry (see below)
|
|
77
|
+
reuseAddress: false, // return same address for same orderId if still pending
|
|
77
78
|
|
|
78
79
|
// Webhooks (optional — set via setupWebhook() instead)
|
|
79
80
|
webhookSecret: process.env.FORGELAYER_WEBHOOK_SECRET,
|
|
80
81
|
|
|
82
|
+
// Storage hooks — recommended for production (see Storage section below)
|
|
83
|
+
async getOrder(sessionKey) { return await db.findOne({ sessionKey }); },
|
|
84
|
+
async saveOrder(sessionKey, order) { await db.insertOne({ sessionKey, ...order }); },
|
|
85
|
+
async updateOrder(sessionKey, patch) { await db.updateOne({ sessionKey }, { $set: patch }); },
|
|
86
|
+
|
|
81
87
|
// Callbacks
|
|
82
88
|
onConfirmed: async (orderId, orderData) => {}, // payment confirmed
|
|
83
89
|
onWebhookEvent: async (event, data) => {}, // any verified webhook event
|
|
@@ -95,6 +101,97 @@ createCheckout({
|
|
|
95
101
|
|
|
96
102
|
---
|
|
97
103
|
|
|
104
|
+
## Storage Hooks
|
|
105
|
+
|
|
106
|
+
By default, orders are kept in a process-local `Map` (fine for development). In production you should plug in your own database:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
// MongoDB example
|
|
110
|
+
const checkout = createCheckout({
|
|
111
|
+
apiKey: process.env.FORGELAYER_API_KEY,
|
|
112
|
+
|
|
113
|
+
async getOrder(sessionKey) {
|
|
114
|
+
return await Order.findOne({ sessionKey }).lean();
|
|
115
|
+
},
|
|
116
|
+
async saveOrder(sessionKey, order) {
|
|
117
|
+
await Order.create({ sessionKey, ...order });
|
|
118
|
+
},
|
|
119
|
+
async updateOrder(sessionKey, patch) {
|
|
120
|
+
await Order.updateOne({ sessionKey }, { $set: patch });
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
onConfirmed: async (orderId, order) => {
|
|
124
|
+
await Order.updateOne({ orderId }, { $set: { paid: true } });
|
|
125
|
+
await sendConfirmationEmail(order.email);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
// PostgreSQL / Prisma example
|
|
132
|
+
const checkout = createCheckout({
|
|
133
|
+
apiKey: process.env.FORGELAYER_API_KEY,
|
|
134
|
+
|
|
135
|
+
async getOrder(sessionKey) {
|
|
136
|
+
return await prisma.order.findUnique({ where: { sessionKey } });
|
|
137
|
+
},
|
|
138
|
+
async saveOrder(sessionKey, order) {
|
|
139
|
+
await prisma.order.create({ data: { sessionKey, ...order } });
|
|
140
|
+
},
|
|
141
|
+
async updateOrder(sessionKey, patch) {
|
|
142
|
+
await prisma.order.update({ where: { sessionKey }, data: patch });
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
All three hooks must be provided together. If any are omitted the plugin falls back to the built-in in-memory store.
|
|
148
|
+
|
|
149
|
+
### In-memory store behaviour
|
|
150
|
+
|
|
151
|
+
The default store is suitable for development and simple single-process deployments:
|
|
152
|
+
- Orders are lost on process restart
|
|
153
|
+
- Does not work with multiple server instances (clusters)
|
|
154
|
+
- Auto-cleans orders older than 24 hours to prevent memory leaks
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Grace Period
|
|
159
|
+
|
|
160
|
+
`gracePeriodMinutes` extends the server-side payment acceptance window beyond what the browser countdown shows.
|
|
161
|
+
|
|
162
|
+
**Use case:** Slow networks or Bitcoin (where a transaction can take hours to confirm after broadcast). The browser sees the timer expire and shows an "expired" UI, but your server continues checking balances and accepting webhook confirmations for the extra time.
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
createCheckout({
|
|
166
|
+
apiKey: '...',
|
|
167
|
+
paymentWindowMinutes: 30, // browser shows 30-min countdown
|
|
168
|
+
gracePeriodMinutes: 60, // server accepts payment for 90 min total
|
|
169
|
+
onConfirmed: async (orderId, order) => {
|
|
170
|
+
// Fires even if payment arrived after the browser countdown ended
|
|
171
|
+
await sendLatePaymentConfirmationEmail(order);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The `onConfirmed` callback receives the order with `status: 'confirmed'` regardless of whether the payment arrived during or after the payment window.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Address Reuse
|
|
181
|
+
|
|
182
|
+
When `reuseAddress: true`, calling `/fl/create` with the same `orderId` within the payment window returns the existing deposit address instead of generating a new one:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
createCheckout({
|
|
186
|
+
apiKey: '...',
|
|
187
|
+
reuseAddress: true, // or pass per-request: { reuseAddress: true } in the POST body
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
This prevents a new address being generated every time a user navigates back to the payment page.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
98
195
|
## Routes
|
|
99
196
|
|
|
100
197
|
Mounted at the path you choose (`app.use('/fl', checkout.middleware())`):
|
|
@@ -116,7 +213,8 @@ Mounted at the path you choose (`app.use('/fl', checkout.middleware())`):
|
|
|
116
213
|
"chain": "ethereum",
|
|
117
214
|
"token": "USDT",
|
|
118
215
|
"orderId": "ORDER-123",
|
|
119
|
-
"paymentWindow": 30
|
|
216
|
+
"paymentWindow": 30,
|
|
217
|
+
"reuseAddress": false
|
|
120
218
|
}
|
|
121
219
|
```
|
|
122
220
|
|
|
@@ -139,6 +237,8 @@ Mounted at the path you choose (`app.use('/fl', checkout.middleware())`):
|
|
|
139
237
|
}
|
|
140
238
|
```
|
|
141
239
|
|
|
240
|
+
When the same address is reused, the response also includes `"reused": true`.
|
|
241
|
+
|
|
142
242
|
### GET /fl/status
|
|
143
243
|
|
|
144
244
|
```
|
|
@@ -220,6 +320,19 @@ Returns an object with:
|
|
|
220
320
|
|
|
221
321
|
---
|
|
222
322
|
|
|
323
|
+
## Changelog
|
|
324
|
+
|
|
325
|
+
### 1.1.0
|
|
326
|
+
- **Storage hooks** — plug in any database via `getOrder` / `saveOrder` / `updateOrder` config options
|
|
327
|
+
- **Grace period** — `gracePeriodMinutes` keeps the server accepting payments after the browser timer expires
|
|
328
|
+
- **Address reuse fix** — `reuseAddress: true` now correctly returns the existing address across restarts when storage hooks are provided
|
|
329
|
+
- **In-memory TTL cleanup** — default store auto-removes orders older than 24 hours
|
|
330
|
+
|
|
331
|
+
### 1.0.0
|
|
332
|
+
- Initial release
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
223
336
|
## License
|
|
224
337
|
|
|
225
338
|
MIT
|
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -10,19 +10,20 @@
|
|
|
10
10
|
* POST <base>/webhook → receive ForgeLayer deposit_confirmed events
|
|
11
11
|
*
|
|
12
12
|
* USAGE (Express):
|
|
13
|
-
* const { createCheckout } = require('forgelayer-
|
|
13
|
+
* const { createCheckout } = require('forgelayer-node');
|
|
14
14
|
* const checkout = createCheckout({ apiKey: 'flk_live_...' });
|
|
15
15
|
* app.use('/fl', checkout.middleware());
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* STORAGE HOOKS (recommended for production):
|
|
18
|
+
* createCheckout({
|
|
19
|
+
* apiKey: '...',
|
|
20
|
+
* async getOrder(sessionKey) { return await db.orders.findOne({ sessionKey }); },
|
|
21
|
+
* async saveOrder(sessionKey, order) { await db.orders.insertOne({ sessionKey, ...order }); },
|
|
22
|
+
* async updateOrder(sessionKey, patch) { await db.orders.updateOne({ sessionKey }, patch); },
|
|
23
|
+
* });
|
|
19
24
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* <button class="fl-checkout-btn"
|
|
23
|
-
* data-fl-amount="49.99" data-fl-currency="USD"
|
|
24
|
-
* data-fl-chain="ethereum" data-fl-token="USDT"
|
|
25
|
-
* data-fl-order-id="order_123">Pay with Crypto</button>
|
|
25
|
+
* Without these hooks the plugin uses an in-memory store (fine for dev/testing,
|
|
26
|
+
* orders are lost on restart).
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
const fs = require('fs');
|
|
@@ -37,47 +38,37 @@ try {
|
|
|
37
38
|
fetchFn = global.fetch;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
const FL_API_BASE
|
|
41
|
-
const CG_API_BASE
|
|
42
|
-
const SDK_VERSION
|
|
41
|
+
const FL_API_BASE = 'https://api.forgelayer.io/v1';
|
|
42
|
+
const CG_API_BASE = 'https://api.coingecko.com/api/v3';
|
|
43
|
+
const SDK_VERSION = '1.1.0';
|
|
43
44
|
|
|
44
45
|
// Stablecoins pegged 1:1 to USD — skip CoinGecko for these
|
|
45
46
|
const USD_STABLECOINS = new Set([
|
|
46
47
|
'USDT', 'USDC', 'BUSD', 'DAI', 'TUSD', 'USDP', 'GUSD', 'FRAX', 'LUSD', 'USDD',
|
|
47
48
|
]);
|
|
48
49
|
|
|
49
|
-
// Token symbol → CoinGecko coin ID
|
|
50
|
+
// Token symbol → CoinGecko coin ID
|
|
50
51
|
const CG_MAP = {
|
|
51
|
-
// Native coins
|
|
52
52
|
ETH: 'ethereum', BNB: 'binancecoin', BTC: 'bitcoin',
|
|
53
53
|
TRX: 'tron',
|
|
54
|
-
// Stablecoins
|
|
55
54
|
USDT: 'tether', USDC: 'usd-coin', BUSD: 'binance-usd',
|
|
56
55
|
DAI: 'dai', TUSD: 'true-usd', USDP: 'pax-dollar',
|
|
57
56
|
FRAX: 'frax', LUSD: 'liquity-usd', GUSD: 'gemini-dollar',
|
|
58
57
|
USDD: 'usdd',
|
|
59
|
-
// Wrapped
|
|
60
58
|
WBTC: 'wrapped-bitcoin', WETH: 'weth', WBNB: 'wbnb',
|
|
61
|
-
// DeFi
|
|
62
59
|
LINK: 'chainlink', UNI: 'uniswap', AAVE: 'aave',
|
|
63
60
|
COMP: 'compound-governance-token', MKR: 'maker', SNX: 'havven',
|
|
64
61
|
YFI: 'yearn-finance', SUSHI: 'sushi', CRV: 'curve-dao-token',
|
|
65
62
|
BAL: 'balancer', LDO: 'lido-dao',
|
|
66
|
-
// L2 / Infra
|
|
67
63
|
MATIC: 'matic-network', ARB: 'arbitrum', OP: 'optimism',
|
|
68
64
|
GRT: 'the-graph',
|
|
69
|
-
// Meme
|
|
70
65
|
SHIB: 'shiba-inu', PEPE: 'pepe', FLOKI: 'floki',
|
|
71
66
|
DOGE: 'dogecoin',
|
|
72
|
-
// Gaming
|
|
73
67
|
SAND: 'the-sandbox', MANA: 'decentraland', AXS: 'axie-infinity',
|
|
74
68
|
APE: 'apecoin', IMX: 'immutable-x', GALA: 'gala',
|
|
75
|
-
// BSC
|
|
76
69
|
CAKE: 'pancakeswap-token', XVS: 'venus',
|
|
77
|
-
// Tron
|
|
78
70
|
BTT: 'bittorrent', WIN: 'wink', JST: 'just',
|
|
79
71
|
SUN: 'sun-token',
|
|
80
|
-
// Other
|
|
81
72
|
CRO: 'crypto-com-chain', BAT: 'basic-attention-token', ZRX: '0x',
|
|
82
73
|
ENS: 'ethereum-name-service', CHZ: 'chiliz', FTM: 'fantom',
|
|
83
74
|
GMT: 'stepn',
|
|
@@ -90,31 +81,37 @@ const CHAIN_NAMES = {
|
|
|
90
81
|
bitcoin: 'Bitcoin',
|
|
91
82
|
};
|
|
92
83
|
|
|
93
|
-
// In-memory
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
84
|
+
// ── In-memory rate cache (process-wide, shared across all requests) ───────────
|
|
85
|
+
const rateCache = new Map();
|
|
86
|
+
|
|
87
|
+
// ── Default in-memory order store ─────────────────────────────────────────────
|
|
88
|
+
// Used when the developer does not supply getOrder/saveOrder/updateOrder hooks.
|
|
89
|
+
// Auto-cleans orders older than 24 hours to prevent memory leaks.
|
|
90
|
+
function createMemoryAdapter() {
|
|
91
|
+
const store = new Map();
|
|
92
|
+
|
|
93
|
+
const cleanupTimer = setInterval(() => {
|
|
94
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
95
|
+
for (const [key, order] of store) {
|
|
96
|
+
if ((order._savedAt || 0) < cutoff) store.delete(key);
|
|
97
|
+
}
|
|
98
|
+
}, 60 * 60 * 1000); // run every hour
|
|
99
|
+
if (cleanupTimer.unref) cleanupTimer.unref();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
async getOrder(key) { return store.get(key) || null; },
|
|
103
|
+
async saveOrder(key, order) { store.set(key, { ...order, _savedAt: Date.now() }); },
|
|
104
|
+
async updateOrder(key, patch) {
|
|
105
|
+
const existing = store.get(key);
|
|
106
|
+
if (existing) store.set(key, { ...existing, ...patch, _savedAt: existing._savedAt });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── ForgeLayer API ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
async function flRequest(method, urlPath, apiKey, body, query) {
|
|
114
|
+
let url = FL_API_BASE + urlPath;
|
|
118
115
|
if (query && Object.keys(query).length) {
|
|
119
116
|
url += '?' + new URLSearchParams(query).toString();
|
|
120
117
|
}
|
|
@@ -128,7 +125,7 @@ async function flRequest(method, path, apiKey, body, query) {
|
|
|
128
125
|
},
|
|
129
126
|
};
|
|
130
127
|
if (body && method !== 'GET') init.body = JSON.stringify(body);
|
|
131
|
-
const res
|
|
128
|
+
const res = await fetchFn(url, init);
|
|
132
129
|
const text = await res.text();
|
|
133
130
|
let json;
|
|
134
131
|
try { json = JSON.parse(text); } catch (_) {
|
|
@@ -151,21 +148,19 @@ async function getBalance(apiKey, address, chain) {
|
|
|
151
148
|
return parseFloat(data.balance ?? 0);
|
|
152
149
|
}
|
|
153
150
|
|
|
154
|
-
// ── CoinGecko
|
|
151
|
+
// ── CoinGecko rates ───────────────────────────────────────────────────────────
|
|
155
152
|
|
|
156
|
-
// Fetch all ~60 coin prices for a given currency in one CoinGecko request and
|
|
157
|
-
// write the result into rateCache. Called by the background timer and, as a
|
|
158
|
-
// one-shot fallback, by getCoinGeckoRate if the cache is completely empty.
|
|
159
153
|
async function fetchAllRates(currency) {
|
|
160
154
|
const cur = currency.toLowerCase();
|
|
161
155
|
const allIds = [...new Set(Object.values(CG_MAP))].join(',');
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
const res = await fetchFn(
|
|
157
|
+
`${CG_API_BASE}/simple/price?ids=${allIds}&vs_currencies=${cur}`,
|
|
158
|
+
{ headers: { 'Accept': 'application/json' } }
|
|
159
|
+
);
|
|
165
160
|
|
|
166
161
|
if (res.status === 429) {
|
|
167
162
|
console.warn('[ForgeLayer] CoinGecko rate limit (429) — keeping existing cache.');
|
|
168
|
-
return;
|
|
163
|
+
return;
|
|
169
164
|
}
|
|
170
165
|
if (!res.ok) {
|
|
171
166
|
console.warn('[ForgeLayer] CoinGecko error ' + res.status + ' — keeping existing cache.');
|
|
@@ -173,8 +168,7 @@ async function fetchAllRates(currency) {
|
|
|
173
168
|
}
|
|
174
169
|
|
|
175
170
|
const json = await res.json();
|
|
176
|
-
|
|
177
|
-
if (json.status && json.status.error_code) {
|
|
171
|
+
if (json.status?.error_code) {
|
|
178
172
|
console.warn('[ForgeLayer] CoinGecko API error:', json.status.error_message);
|
|
179
173
|
return;
|
|
180
174
|
}
|
|
@@ -183,41 +177,30 @@ async function fetchAllRates(currency) {
|
|
|
183
177
|
for (const [id, prices] of Object.entries(json)) {
|
|
184
178
|
if (prices[cur] != null) rates[id] = parseFloat(prices[cur]);
|
|
185
179
|
}
|
|
186
|
-
|
|
187
180
|
if (Object.keys(rates).length > 0) {
|
|
188
181
|
rateCache.set('batch_' + cur, { rates, at: Date.now() });
|
|
189
182
|
}
|
|
190
183
|
}
|
|
191
184
|
|
|
192
|
-
// Read the cached rate for a token/currency pair.
|
|
193
|
-
// Falls back to a one-shot fetch ONLY on the very first call before the
|
|
194
|
-
// background timer has had a chance to populate the cache.
|
|
195
185
|
async function getCoinGeckoRate(token, currency) {
|
|
196
186
|
const sym = token.toUpperCase();
|
|
197
187
|
const cur = currency.toLowerCase();
|
|
198
188
|
|
|
199
|
-
// Stablecoins are always ≈ 1 USD — never hit CoinGecko
|
|
200
189
|
if (cur === 'usd' && USD_STABLECOINS.has(sym)) return 1.0;
|
|
201
190
|
|
|
202
191
|
const coinId = CG_MAP[sym];
|
|
203
192
|
if (!coinId) {
|
|
204
|
-
throw new Error(
|
|
205
|
-
'No CoinGecko mapping for token: ' + token +
|
|
206
|
-
'. Supported: ' + Object.keys(CG_MAP).join(', ')
|
|
207
|
-
);
|
|
193
|
+
throw new Error('No CoinGecko mapping for token: ' + token + '. Supported: ' + Object.keys(CG_MAP).join(', '));
|
|
208
194
|
}
|
|
209
195
|
|
|
210
196
|
const cached = rateCache.get('batch_' + cur);
|
|
211
|
-
|
|
212
|
-
// Cache is populated and fresh — use it (normal path after background timer runs)
|
|
213
197
|
if (cached && Object.keys(cached.rates).length > 0) {
|
|
214
198
|
const rate = parseFloat(cached.rates[coinId] ?? 0);
|
|
215
199
|
if (rate > 0) return rate;
|
|
216
200
|
}
|
|
217
201
|
|
|
218
|
-
//
|
|
202
|
+
// First call before background timer has fired — fetch once now
|
|
219
203
|
await fetchAllRates(cur);
|
|
220
|
-
|
|
221
204
|
const refreshed = rateCache.get('batch_' + cur);
|
|
222
205
|
const rate = parseFloat(refreshed?.rates[coinId] ?? 0);
|
|
223
206
|
if (rate <= 0) {
|
|
@@ -229,71 +212,114 @@ async function getCoinGeckoRate(token, currency) {
|
|
|
229
212
|
// ── Middleware factory ────────────────────────────────────────────────────────
|
|
230
213
|
|
|
231
214
|
function createCheckout(config) {
|
|
232
|
-
if (!config || !config.apiKey)
|
|
233
|
-
throw new Error('ForgeLayer: apiKey is required.');
|
|
234
|
-
}
|
|
215
|
+
if (!config || !config.apiKey) throw new Error('ForgeLayer: apiKey is required.');
|
|
235
216
|
|
|
236
|
-
const apiKey
|
|
237
|
-
|
|
238
|
-
let webhookSecret = String(
|
|
217
|
+
const apiKey = String(config.apiKey).trim();
|
|
218
|
+
let webhookSecret = String(
|
|
239
219
|
config.webhookSecret || process.env.FORGELAYER_WEBHOOK_SECRET || loadSavedWebhookSecret() || ''
|
|
240
220
|
).trim();
|
|
241
|
-
const defaultCurrency
|
|
242
|
-
const defaultChain
|
|
243
|
-
const defaultToken
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
const
|
|
221
|
+
const defaultCurrency = (config.currency || 'USD').toUpperCase();
|
|
222
|
+
const defaultChain = config.defaultChain || 'ethereum';
|
|
223
|
+
const defaultToken = (config.defaultToken || 'USDT').toUpperCase();
|
|
224
|
+
const defaultWindow = Math.max(1, +(config.paymentWindowMinutes || 30));
|
|
225
|
+
const defaultReuse = !!config.reuseAddress;
|
|
226
|
+
const gracePeriodSeconds = Math.max(0, +(config.gracePeriodMinutes || 0)) * 60;
|
|
227
|
+
const onConfirmed = config.onConfirmed || null;
|
|
228
|
+
const onWebhookEvent = config.onWebhookEvent || null;
|
|
229
|
+
|
|
230
|
+
// ── Storage adapter ─────────────────────────────────────────────────────────
|
|
231
|
+
// Use developer-supplied hooks if provided, otherwise fall back to in-memory.
|
|
232
|
+
const storage = (config.getOrder && config.saveOrder && config.updateOrder)
|
|
233
|
+
? {
|
|
234
|
+
getOrder: config.getOrder.bind(config),
|
|
235
|
+
saveOrder: config.saveOrder.bind(config),
|
|
236
|
+
updateOrder: config.updateOrder.bind(config),
|
|
237
|
+
}
|
|
238
|
+
: createMemoryAdapter();
|
|
239
|
+
|
|
240
|
+
if (!config.getOrder) {
|
|
241
|
+
console.warn(
|
|
242
|
+
'[ForgeLayer] No storage hooks provided — using in-memory store. ' +
|
|
243
|
+
'Orders will be lost on restart. Pass getOrder/saveOrder/updateOrder for production.'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
248
246
|
|
|
249
247
|
// ── Background rate refresh ─────────────────────────────────────────────────
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const _refreshCurrency = defaultCurrency.toLowerCase();
|
|
253
|
-
fetchAllRates(_refreshCurrency).catch(e =>
|
|
254
|
-
console.warn('[ForgeLayer] Initial rate fetch failed:', e.message)
|
|
255
|
-
);
|
|
248
|
+
const _cur = defaultCurrency.toLowerCase();
|
|
249
|
+
fetchAllRates(_cur).catch(e => console.warn('[ForgeLayer] Initial rate fetch failed:', e.message));
|
|
256
250
|
const _rateTimer = setInterval(
|
|
257
|
-
() => fetchAllRates(
|
|
258
|
-
console.warn('[ForgeLayer] Rate refresh failed:', e.message)
|
|
259
|
-
),
|
|
251
|
+
() => fetchAllRates(_cur).catch(e => console.warn('[ForgeLayer] Rate refresh failed:', e.message)),
|
|
260
252
|
60_000
|
|
261
253
|
);
|
|
262
|
-
// Don't block process exit — the interval is fire-and-forget
|
|
263
254
|
if (_rateTimer.unref) _rateTimer.unref();
|
|
264
255
|
|
|
265
256
|
// Read browser.js once at startup
|
|
266
|
-
const
|
|
267
|
-
const browserJsRaw = fs.readFileSync(browserJsPath, 'utf8');
|
|
257
|
+
const browserJsRaw = fs.readFileSync(path.join(__dirname, 'browser.js'), 'utf8');
|
|
268
258
|
|
|
269
|
-
// ──
|
|
259
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
270
260
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
261
|
+
function toSessionKey(orderId) {
|
|
262
|
+
return 'fl_' + String(orderId).replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function markConfirmed(sessionKey, order) {
|
|
266
|
+
await storage.updateOrder(sessionKey, { status: 'confirmed' });
|
|
267
|
+
if (onConfirmed) {
|
|
268
|
+
onConfirmed(order.orderId, { ...order, status: 'confirmed' })
|
|
269
|
+
.catch(e => console.error('[ForgeLayer] onConfirmed error:', e));
|
|
274
270
|
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── POST /create ─────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
async function handleCreate(req, res) {
|
|
276
|
+
if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
|
|
275
277
|
|
|
276
278
|
let body = req.body;
|
|
277
279
|
if (!body || typeof body !== 'object') {
|
|
278
|
-
try {
|
|
279
|
-
const raw = await readBody(req);
|
|
280
|
-
body = JSON.parse(raw || '{}');
|
|
281
|
-
} catch (_) {
|
|
282
|
-
body = {};
|
|
283
|
-
}
|
|
280
|
+
try { body = JSON.parse(await readBody(req) || '{}'); } catch (_) { body = {}; }
|
|
284
281
|
}
|
|
285
282
|
|
|
286
|
-
const amount
|
|
287
|
-
const currency
|
|
288
|
-
const chain
|
|
289
|
-
const token
|
|
290
|
-
const orderId
|
|
291
|
-
const reuse
|
|
292
|
-
const window_
|
|
293
|
-
|
|
294
|
-
if (amount <= 0)
|
|
295
|
-
if (!CHAIN_NAMES[chain])
|
|
283
|
+
const amount = parseFloat(body.amount || 0);
|
|
284
|
+
const currency = (body.currency || defaultCurrency).toUpperCase();
|
|
285
|
+
const chain = (body.chain || defaultChain).toLowerCase();
|
|
286
|
+
const token = (body.token || defaultToken).toUpperCase();
|
|
287
|
+
const orderId = body.orderId || ('fl_' + Date.now() + '_' + Math.random().toString(36).slice(2));
|
|
288
|
+
const reuse = body.reuseAddress !== undefined ? !!body.reuseAddress : defaultReuse;
|
|
289
|
+
const window_ = Math.max(1, +(body.paymentWindow || defaultWindow));
|
|
290
|
+
|
|
291
|
+
if (amount <= 0) return res.status(400).json({ ok: false, error: 'Amount must be > 0.' });
|
|
292
|
+
if (!CHAIN_NAMES[chain]) return res.status(400).json({ ok: false, error: 'Unsupported chain: ' + chain });
|
|
293
|
+
|
|
294
|
+
const sessionKey = toSessionKey(orderId);
|
|
295
|
+
|
|
296
|
+
// ── Address reuse ─────────────────────────────────────────────────────────
|
|
297
|
+
// If reuseAddress is true and an active order already exists for this orderId,
|
|
298
|
+
// return the existing address instead of generating a new one.
|
|
299
|
+
if (reuse) {
|
|
300
|
+
const existing = await storage.getOrder(sessionKey);
|
|
301
|
+
if (existing && existing.status === 'pending') {
|
|
302
|
+
const now = Math.floor(Date.now() / 1000);
|
|
303
|
+
if (now < existing.expiresAt) {
|
|
304
|
+
return res.json({
|
|
305
|
+
ok: true, reused: true,
|
|
306
|
+
address: existing.address,
|
|
307
|
+
chain: existing.chain,
|
|
308
|
+
chainName: CHAIN_NAMES[existing.chain] || existing.chain,
|
|
309
|
+
token: existing.token,
|
|
310
|
+
amount: existing.amount,
|
|
311
|
+
currency: existing.currency,
|
|
312
|
+
cryptoAmount: existing.cryptoAmount,
|
|
313
|
+
expiresAt: existing.expiresAt,
|
|
314
|
+
orderId: existing.orderId,
|
|
315
|
+
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(existing.address),
|
|
316
|
+
sessionKey,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
296
321
|
|
|
322
|
+
// ── Generate new address ──────────────────────────────────────────────────
|
|
297
323
|
let address;
|
|
298
324
|
try { address = await generateAddress(apiKey, chain, orderId); }
|
|
299
325
|
catch (e) { return res.status(500).json({ ok: false, error: 'Address generation failed: ' + e.message }); }
|
|
@@ -302,119 +328,84 @@ function createCheckout(config) {
|
|
|
302
328
|
try {
|
|
303
329
|
const rate = await getCoinGeckoRate(token, currency);
|
|
304
330
|
if (rate > 0) cryptoAmount = (amount / rate).toFixed(8).replace(/\.?0+$/, '');
|
|
305
|
-
} catch (_) { /* show fiat only */ }
|
|
331
|
+
} catch (_) { /* show fiat amount only */ }
|
|
306
332
|
|
|
307
|
-
const expiresAt
|
|
308
|
-
const
|
|
309
|
-
const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(address);
|
|
333
|
+
const expiresAt = Math.floor(Date.now() / 1000) + window_ * 60;
|
|
334
|
+
const order = { orderId, address, chain, token, amount, currency, cryptoAmount, expiresAt, status: 'pending' };
|
|
310
335
|
|
|
311
|
-
|
|
312
|
-
orderStore.set(sessionKey, {
|
|
313
|
-
orderId, address, chain, token, amount, currency,
|
|
314
|
-
cryptoAmount, expiresAt, status: 'pending',
|
|
315
|
-
});
|
|
336
|
+
await storage.saveOrder(sessionKey, order);
|
|
316
337
|
|
|
317
338
|
return res.json({
|
|
318
339
|
ok: true,
|
|
319
340
|
address, chain,
|
|
320
341
|
chainName: CHAIN_NAMES[chain] || chain,
|
|
321
342
|
token, amount, currency, cryptoAmount,
|
|
322
|
-
expiresAt, orderId,
|
|
343
|
+
expiresAt, orderId, sessionKey,
|
|
344
|
+
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(address),
|
|
323
345
|
});
|
|
324
346
|
}
|
|
325
347
|
|
|
348
|
+
// ── GET /status ───────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
326
350
|
async function handleStatus(req, res) {
|
|
327
|
-
const
|
|
328
|
-
const sessionKey = req.query?.session
|
|
351
|
+
const qs = new URLSearchParams((req.url || '').split('?')[1] || '');
|
|
352
|
+
const sessionKey = req.query?.session || qs.get('session') || '';
|
|
353
|
+
const orderIdQ = req.query?.orderId || qs.get('orderId') || '';
|
|
354
|
+
const key = sessionKey || toSessionKey(orderIdQ);
|
|
329
355
|
|
|
330
|
-
const
|
|
331
|
-
const order = orderStore.get(key);
|
|
356
|
+
const order = await storage.getOrder(key);
|
|
332
357
|
if (!order) return res.status(404).json({ ok: false, error: 'Order not found.' });
|
|
333
358
|
|
|
359
|
+
// Already confirmed
|
|
334
360
|
if (order.status === 'confirmed') return res.json({ ok: true, status: 'confirmed' });
|
|
335
361
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
362
|
+
const now = Math.floor(Date.now() / 1000);
|
|
363
|
+
const graceEndsAt = order.expiresAt + gracePeriodSeconds;
|
|
364
|
+
|
|
365
|
+
// Past grace period entirely — hard expired, no more checks
|
|
366
|
+
if (now >= graceEndsAt) {
|
|
367
|
+
await storage.updateOrder(key, { status: 'expired' });
|
|
339
368
|
return res.json({ ok: true, status: 'expired' });
|
|
340
369
|
}
|
|
341
370
|
|
|
342
|
-
// Check balance
|
|
371
|
+
// Check balance — works both within and outside the payment window
|
|
343
372
|
try {
|
|
344
373
|
const balance = await getBalance(apiKey, order.address, order.chain);
|
|
345
374
|
const expected = parseFloat(order.cryptoAmount || 0);
|
|
346
375
|
if (expected > 0 && balance >= expected * 0.99) {
|
|
347
|
-
|
|
348
|
-
if (onConfirmed) {
|
|
349
|
-
onConfirmed(order.orderId, order).catch(e => console.error('[ForgeLayer] onConfirmed error:', e));
|
|
350
|
-
}
|
|
376
|
+
await markConfirmed(key, order);
|
|
351
377
|
return res.json({ ok: true, status: 'confirmed' });
|
|
352
378
|
}
|
|
353
379
|
} catch (_) { /* best-effort */ }
|
|
354
380
|
|
|
381
|
+
// Payment window closed — tell the browser "expired" so it stops showing the UI,
|
|
382
|
+
// but we keep accepting via webhook until the grace period ends.
|
|
383
|
+
if (now >= order.expiresAt) {
|
|
384
|
+
return res.json({ ok: true, status: 'expired' });
|
|
385
|
+
}
|
|
386
|
+
|
|
355
387
|
return res.json({ ok: true, status: 'pending' });
|
|
356
388
|
}
|
|
357
389
|
|
|
390
|
+
// ── GET /checkout.js ──────────────────────────────────────────────────────────
|
|
391
|
+
|
|
358
392
|
function serveBrowserScript(req, res, basePath) {
|
|
359
|
-
const
|
|
360
|
-
const statusUrl = basePath + '/status';
|
|
361
|
-
const config_js = 'var FL_CONFIG={"createUrl":"' + createUrl + '","statusUrl":"' + statusUrl + '"};';
|
|
393
|
+
const config_js = 'var FL_CONFIG={"createUrl":"' + basePath + '/create","statusUrl":"' + basePath + '/status"};';
|
|
362
394
|
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
363
395
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
364
396
|
res.end(config_js + '\n' + browserJsRaw);
|
|
365
397
|
}
|
|
366
398
|
|
|
367
|
-
// ──
|
|
368
|
-
|
|
369
|
-
async function setupWebhook(webhookUrl, confirmations) {
|
|
370
|
-
if (!webhookUrl || typeof webhookUrl !== 'string') {
|
|
371
|
-
throw new Error('setupWebhook: webhookUrl is required.');
|
|
372
|
-
}
|
|
373
|
-
confirmations = Math.max(1, +(confirmations || 1));
|
|
374
|
-
|
|
375
|
-
// Generate a fresh secret
|
|
376
|
-
const newSecret = crypto.randomBytes(32).toString('hex');
|
|
377
|
-
|
|
378
|
-
// Delete the old webhook if we have its ID on file
|
|
379
|
-
const oldId = loadSavedWebhookId();
|
|
380
|
-
if (oldId) {
|
|
381
|
-
try {
|
|
382
|
-
await flRequest('DELETE', '/webhooks/' + encodeURIComponent(oldId), apiKey);
|
|
383
|
-
} catch (_) { /* best-effort — old webhook may already be gone */ }
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Register with ForgeLayer
|
|
387
|
-
const data = await flRequest('POST', '/webhooks', apiKey, {
|
|
388
|
-
url: webhookUrl,
|
|
389
|
-
secret: newSecret,
|
|
390
|
-
events: ['deposit_confirmed'],
|
|
391
|
-
confirmations,
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
const webhookId = data.id || data.webhookId || '';
|
|
395
|
-
|
|
396
|
-
// Persist for next restart
|
|
397
|
-
saveWebhookSecret(newSecret);
|
|
398
|
-
if (webhookId) saveWebhookId(webhookId);
|
|
399
|
-
|
|
400
|
-
// Update the live secret so the running process verifies correctly immediately
|
|
401
|
-
webhookSecret = newSecret;
|
|
402
|
-
|
|
403
|
-
return { webhookId, webhookUrl, confirmations };
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// ── Webhook handler ─────────────────────────────────────────────────────────
|
|
399
|
+
// ── POST /webhook ─────────────────────────────────────────────────────────────
|
|
407
400
|
|
|
408
401
|
async function handleWebhook(req, res) {
|
|
409
|
-
if (req.method !== 'POST') {
|
|
410
|
-
return res.status(405).json({ ok: false, error: 'POST required.' });
|
|
411
|
-
}
|
|
402
|
+
if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
|
|
412
403
|
|
|
413
404
|
const rawBody = await readBody(req);
|
|
414
405
|
const sig = req.headers['x-fl-signature'] || '';
|
|
415
406
|
|
|
416
407
|
if (!webhookSecret) {
|
|
417
|
-
console.error('[ForgeLayer] Webhook received but no webhookSecret
|
|
408
|
+
console.error('[ForgeLayer] Webhook received but no webhookSecret configured. Call setupWebhook() first.');
|
|
418
409
|
return res.status(500).json({ ok: false, error: 'Webhook secret not configured.' });
|
|
419
410
|
}
|
|
420
411
|
|
|
@@ -424,49 +415,72 @@ function createCheckout(config) {
|
|
|
424
415
|
}
|
|
425
416
|
|
|
426
417
|
let event;
|
|
427
|
-
try { event = JSON.parse(rawBody); }
|
|
428
|
-
|
|
429
|
-
}
|
|
418
|
+
try { event = JSON.parse(rawBody); }
|
|
419
|
+
catch (_) { return res.status(400).json({ ok: false, error: 'Invalid JSON body.' }); }
|
|
430
420
|
|
|
431
421
|
if (event.event === 'deposit_confirmed') {
|
|
432
422
|
const orderId = event.data?.orderId || event.data?.label || '';
|
|
433
|
-
const sessionKey =
|
|
434
|
-
const order =
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
423
|
+
const sessionKey = toSessionKey(orderId);
|
|
424
|
+
const order = await storage.getOrder(sessionKey);
|
|
425
|
+
|
|
426
|
+
if (order && order.status !== 'confirmed') {
|
|
427
|
+
const now = Math.floor(Date.now() / 1000);
|
|
428
|
+
const graceEndsAt = order.expiresAt + gracePeriodSeconds;
|
|
429
|
+
|
|
430
|
+
// Accept payment if within the payment window OR within the grace period
|
|
431
|
+
if (now < graceEndsAt) {
|
|
432
|
+
await markConfirmed(sessionKey, order);
|
|
433
|
+
} else {
|
|
434
|
+
console.warn('[ForgeLayer] Late payment received for order ' + orderId + ' but grace period has ended.');
|
|
439
435
|
}
|
|
440
436
|
}
|
|
441
437
|
}
|
|
442
438
|
|
|
443
439
|
if (onWebhookEvent) {
|
|
444
|
-
onWebhookEvent(event.event, event.data)
|
|
440
|
+
onWebhookEvent(event.event, event.data)
|
|
441
|
+
.catch(e => console.error('[ForgeLayer] onWebhookEvent error:', e));
|
|
445
442
|
}
|
|
446
443
|
|
|
447
444
|
return res.status(200).json({ ok: true });
|
|
448
445
|
}
|
|
449
446
|
|
|
450
|
-
// ──
|
|
447
|
+
// ── Webhook registration ──────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
async function setupWebhook(webhookUrl, confirmations) {
|
|
450
|
+
if (!webhookUrl || typeof webhookUrl !== 'string') throw new Error('setupWebhook: webhookUrl is required.');
|
|
451
|
+
confirmations = Math.max(1, +(confirmations || 1));
|
|
452
|
+
|
|
453
|
+
const newSecret = crypto.randomBytes(32).toString('hex');
|
|
454
|
+
|
|
455
|
+
const oldId = loadSavedWebhookId();
|
|
456
|
+
if (oldId) {
|
|
457
|
+
try { await flRequest('DELETE', '/webhooks/' + encodeURIComponent(oldId), apiKey); }
|
|
458
|
+
catch (_) {}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const data = await flRequest('POST', '/webhooks', apiKey, {
|
|
462
|
+
url: webhookUrl, secret: newSecret, events: ['deposit_confirmed'], confirmations,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const webhookId = data.id || data.webhookId || '';
|
|
466
|
+
saveWebhookSecret(newSecret);
|
|
467
|
+
if (webhookId) saveWebhookId(webhookId);
|
|
468
|
+
webhookSecret = newSecret;
|
|
469
|
+
|
|
470
|
+
return { webhookId, webhookUrl, confirmations };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Express middleware ────────────────────────────────────────────────────────
|
|
451
474
|
|
|
452
475
|
function middleware() {
|
|
453
476
|
return function forgeLayerMiddleware(req, res, next) {
|
|
454
|
-
// Determine the path relative to where this middleware is mounted
|
|
455
477
|
const mountPath = req.baseUrl || '';
|
|
456
478
|
const urlPath = (req.path || '/').replace(/\/$/, '') || '/';
|
|
457
479
|
|
|
458
|
-
if (urlPath === '/checkout.js' && req.method === 'GET')
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (urlPath === '/
|
|
462
|
-
return handleCreate(req, res, mountPath).catch(next);
|
|
463
|
-
}
|
|
464
|
-
if (urlPath === '/status' && req.method === 'GET') {
|
|
465
|
-
return handleStatus(req, res).catch(next);
|
|
466
|
-
}
|
|
467
|
-
if (urlPath === '/webhook' && req.method === 'POST') {
|
|
468
|
-
return handleWebhook(req, res).catch(next);
|
|
469
|
-
}
|
|
480
|
+
if (urlPath === '/checkout.js' && req.method === 'GET') return serveBrowserScript(req, res, mountPath);
|
|
481
|
+
if (urlPath === '/create' && req.method === 'POST') return handleCreate(req, res).catch(next);
|
|
482
|
+
if (urlPath === '/status' && req.method === 'GET') return handleStatus(req, res).catch(next);
|
|
483
|
+
if (urlPath === '/webhook' && req.method === 'POST') return handleWebhook(req, res).catch(next);
|
|
470
484
|
return next();
|
|
471
485
|
};
|
|
472
486
|
}
|
|
@@ -474,11 +488,10 @@ function createCheckout(config) {
|
|
|
474
488
|
return { middleware, setupWebhook, handleCreate, handleStatus, handleWebhook, serveBrowserScript };
|
|
475
489
|
}
|
|
476
490
|
|
|
477
|
-
// ── Helpers
|
|
491
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
478
492
|
|
|
479
493
|
function readBody(req) {
|
|
480
494
|
return new Promise(function (resolve, reject) {
|
|
481
|
-
// If Express (or body-parser) already consumed the body, return it as a raw Buffer
|
|
482
495
|
if (req.body !== undefined) {
|
|
483
496
|
const raw = typeof req.body === 'string'
|
|
484
497
|
? req.body
|
|
@@ -486,13 +499,13 @@ function readBody(req) {
|
|
|
486
499
|
return resolve(raw);
|
|
487
500
|
}
|
|
488
501
|
const chunks = [];
|
|
489
|
-
req.on('data',
|
|
490
|
-
req.on('end',
|
|
502
|
+
req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
503
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
491
504
|
req.on('error', reject);
|
|
492
505
|
});
|
|
493
506
|
}
|
|
494
507
|
|
|
495
|
-
// ── Webhook secret/ID
|
|
508
|
+
// ── Webhook secret/ID persistence ─────────────────────────────────────────────
|
|
496
509
|
|
|
497
510
|
const SECRET_FILE = path.join(__dirname, '..', '.fl_webhook_secret');
|
|
498
511
|
const ID_FILE = path.join(__dirname, '..', '.fl_webhook_id');
|
|
@@ -500,15 +513,12 @@ const ID_FILE = path.join(__dirname, '..', '.fl_webhook_id');
|
|
|
500
513
|
function loadSavedWebhookSecret() {
|
|
501
514
|
try { return fs.readFileSync(SECRET_FILE, 'utf8').trim(); } catch (_) { return ''; }
|
|
502
515
|
}
|
|
503
|
-
|
|
504
516
|
function saveWebhookSecret(secret) {
|
|
505
517
|
fs.writeFileSync(SECRET_FILE, secret, { encoding: 'utf8', mode: 0o600 });
|
|
506
518
|
}
|
|
507
|
-
|
|
508
519
|
function loadSavedWebhookId() {
|
|
509
520
|
try { return fs.readFileSync(ID_FILE, 'utf8').trim(); } catch (_) { return ''; }
|
|
510
521
|
}
|
|
511
|
-
|
|
512
522
|
function saveWebhookId(id) {
|
|
513
523
|
fs.writeFileSync(ID_FILE, id, 'utf8');
|
|
514
524
|
}
|