forgelayer-node 1.0.0 → 1.1.1
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 +237 -221
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.1';
|
|
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,115 @@ 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
|
+
// SHA-256 so ORDER-1 and ORDER_1 don't collapse to the same key
|
|
263
|
+
return 'fl_' + crypto.createHash('sha256').update(String(orderId)).digest('hex').slice(0, 32);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function markConfirmed(sessionKey, order) {
|
|
267
|
+
await storage.updateOrder(sessionKey, { status: 'confirmed' });
|
|
268
|
+
if (onConfirmed) {
|
|
269
|
+
onConfirmed(order.orderId, { ...order, status: 'confirmed' })
|
|
270
|
+
.catch(e => console.error('[ForgeLayer] onConfirmed error:', e));
|
|
274
271
|
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── POST /create ─────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
async function handleCreate(req, res) {
|
|
277
|
+
if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
|
|
275
278
|
|
|
276
279
|
let body = req.body;
|
|
277
280
|
if (!body || typeof body !== 'object') {
|
|
278
|
-
try {
|
|
279
|
-
const raw = await readBody(req);
|
|
280
|
-
body = JSON.parse(raw || '{}');
|
|
281
|
-
} catch (_) {
|
|
282
|
-
body = {};
|
|
283
|
-
}
|
|
281
|
+
try { body = JSON.parse(await readBody(req) || '{}'); } catch (_) { body = {}; }
|
|
284
282
|
}
|
|
285
283
|
|
|
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])
|
|
284
|
+
const amount = parseFloat(body.amount || 0);
|
|
285
|
+
const currency = (body.currency || defaultCurrency).toUpperCase();
|
|
286
|
+
const chain = (body.chain || defaultChain).toLowerCase();
|
|
287
|
+
const token = (body.token || defaultToken).toUpperCase();
|
|
288
|
+
const orderId = body.orderId || ('fl_' + Date.now() + '_' + Math.random().toString(36).slice(2));
|
|
289
|
+
const reuse = body.reuseAddress !== undefined ? !!body.reuseAddress : defaultReuse;
|
|
290
|
+
const window_ = Math.max(1, +(body.paymentWindow || defaultWindow));
|
|
291
|
+
|
|
292
|
+
if (amount <= 0) return res.status(400).json({ ok: false, error: 'Amount must be > 0.' });
|
|
293
|
+
if (!CHAIN_NAMES[chain]) return res.status(400).json({ ok: false, error: 'Unsupported chain: ' + chain });
|
|
294
|
+
|
|
295
|
+
const sessionKey = toSessionKey(orderId);
|
|
296
|
+
|
|
297
|
+
// ── Address reuse ─────────────────────────────────────────────────────────
|
|
298
|
+
// If reuseAddress is true and an active order already exists for this orderId,
|
|
299
|
+
// return the existing address instead of generating a new one.
|
|
300
|
+
if (reuse) {
|
|
301
|
+
const existing = await storage.getOrder(sessionKey);
|
|
302
|
+
if (existing && existing.status === 'pending') {
|
|
303
|
+
const now = Math.floor(Date.now() / 1000);
|
|
304
|
+
if (now < existing.expiresAt) {
|
|
305
|
+
return res.json({
|
|
306
|
+
ok: true, reused: true,
|
|
307
|
+
address: existing.address,
|
|
308
|
+
chain: existing.chain,
|
|
309
|
+
chainName: CHAIN_NAMES[existing.chain] || existing.chain,
|
|
310
|
+
token: existing.token,
|
|
311
|
+
amount: existing.amount,
|
|
312
|
+
currency: existing.currency,
|
|
313
|
+
cryptoAmount: existing.cryptoAmount,
|
|
314
|
+
expiresAt: existing.expiresAt,
|
|
315
|
+
orderId: existing.orderId,
|
|
316
|
+
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(existing.address),
|
|
317
|
+
sessionKey,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
296
322
|
|
|
323
|
+
// ── Generate new address ──────────────────────────────────────────────────
|
|
297
324
|
let address;
|
|
298
325
|
try { address = await generateAddress(apiKey, chain, orderId); }
|
|
299
326
|
catch (e) { return res.status(500).json({ ok: false, error: 'Address generation failed: ' + e.message }); }
|
|
@@ -302,171 +329,162 @@ function createCheckout(config) {
|
|
|
302
329
|
try {
|
|
303
330
|
const rate = await getCoinGeckoRate(token, currency);
|
|
304
331
|
if (rate > 0) cryptoAmount = (amount / rate).toFixed(8).replace(/\.?0+$/, '');
|
|
305
|
-
} catch (_) { /* show fiat only */ }
|
|
332
|
+
} catch (_) { /* show fiat amount only */ }
|
|
306
333
|
|
|
307
|
-
const expiresAt
|
|
308
|
-
const
|
|
309
|
-
const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(address);
|
|
334
|
+
const expiresAt = Math.floor(Date.now() / 1000) + window_ * 60;
|
|
335
|
+
const order = { orderId, address, chain, token, amount, currency, cryptoAmount, expiresAt, status: 'pending' };
|
|
310
336
|
|
|
311
|
-
|
|
312
|
-
orderStore.set(sessionKey, {
|
|
313
|
-
orderId, address, chain, token, amount, currency,
|
|
314
|
-
cryptoAmount, expiresAt, status: 'pending',
|
|
315
|
-
});
|
|
337
|
+
await storage.saveOrder(sessionKey, order);
|
|
316
338
|
|
|
317
339
|
return res.json({
|
|
318
340
|
ok: true,
|
|
319
341
|
address, chain,
|
|
320
342
|
chainName: CHAIN_NAMES[chain] || chain,
|
|
321
343
|
token, amount, currency, cryptoAmount,
|
|
322
|
-
expiresAt, orderId,
|
|
344
|
+
expiresAt, orderId, sessionKey,
|
|
345
|
+
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(address),
|
|
323
346
|
});
|
|
324
347
|
}
|
|
325
348
|
|
|
349
|
+
// ── GET /status ───────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
326
351
|
async function handleStatus(req, res) {
|
|
327
|
-
const
|
|
328
|
-
const sessionKey = req.query?.session
|
|
352
|
+
const qs = new URLSearchParams((req.url || '').split('?')[1] || '');
|
|
353
|
+
const sessionKey = req.query?.session || qs.get('session') || '';
|
|
354
|
+
const orderIdQ = req.query?.orderId || qs.get('orderId') || '';
|
|
355
|
+
const key = sessionKey || toSessionKey(orderIdQ);
|
|
329
356
|
|
|
330
|
-
const
|
|
331
|
-
const order = orderStore.get(key);
|
|
357
|
+
const order = await storage.getOrder(key);
|
|
332
358
|
if (!order) return res.status(404).json({ ok: false, error: 'Order not found.' });
|
|
333
359
|
|
|
360
|
+
// Already confirmed
|
|
334
361
|
if (order.status === 'confirmed') return res.json({ ok: true, status: 'confirmed' });
|
|
335
362
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
363
|
+
const now = Math.floor(Date.now() / 1000);
|
|
364
|
+
const graceEndsAt = order.expiresAt + gracePeriodSeconds;
|
|
365
|
+
|
|
366
|
+
// Past grace period entirely — hard expired, no more checks
|
|
367
|
+
if (now >= graceEndsAt) {
|
|
368
|
+
await storage.updateOrder(key, { status: 'expired' });
|
|
339
369
|
return res.json({ ok: true, status: 'expired' });
|
|
340
370
|
}
|
|
341
371
|
|
|
342
|
-
// Check balance
|
|
372
|
+
// Check balance — works both within and outside the payment window
|
|
343
373
|
try {
|
|
344
374
|
const balance = await getBalance(apiKey, order.address, order.chain);
|
|
345
375
|
const expected = parseFloat(order.cryptoAmount || 0);
|
|
346
376
|
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
|
-
}
|
|
377
|
+
await markConfirmed(key, order);
|
|
351
378
|
return res.json({ ok: true, status: 'confirmed' });
|
|
352
379
|
}
|
|
353
380
|
} catch (_) { /* best-effort */ }
|
|
354
381
|
|
|
382
|
+
// Payment window closed — tell the browser "expired" so it stops showing the UI,
|
|
383
|
+
// but we keep accepting via webhook until the grace period ends.
|
|
384
|
+
if (now >= order.expiresAt) {
|
|
385
|
+
return res.json({ ok: true, status: 'expired' });
|
|
386
|
+
}
|
|
387
|
+
|
|
355
388
|
return res.json({ ok: true, status: 'pending' });
|
|
356
389
|
}
|
|
357
390
|
|
|
391
|
+
// ── GET /checkout.js ──────────────────────────────────────────────────────────
|
|
392
|
+
|
|
358
393
|
function serveBrowserScript(req, res, basePath) {
|
|
359
|
-
const
|
|
360
|
-
const statusUrl = basePath + '/status';
|
|
361
|
-
const config_js = 'var FL_CONFIG={"createUrl":"' + createUrl + '","statusUrl":"' + statusUrl + '"};';
|
|
394
|
+
const config_js = 'var FL_CONFIG={"createUrl":"' + basePath + '/create","statusUrl":"' + basePath + '/status"};';
|
|
362
395
|
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
363
396
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
364
397
|
res.end(config_js + '\n' + browserJsRaw);
|
|
365
398
|
}
|
|
366
399
|
|
|
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 ─────────────────────────────────────────────────────────
|
|
400
|
+
// ── POST /webhook ─────────────────────────────────────────────────────────────
|
|
407
401
|
|
|
408
402
|
async function handleWebhook(req, res) {
|
|
409
|
-
if (req.method !== 'POST') {
|
|
410
|
-
return res.status(405).json({ ok: false, error: 'POST required.' });
|
|
411
|
-
}
|
|
403
|
+
if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
|
|
412
404
|
|
|
413
405
|
const rawBody = await readBody(req);
|
|
414
406
|
const sig = req.headers['x-fl-signature'] || '';
|
|
415
407
|
|
|
416
408
|
if (!webhookSecret) {
|
|
417
|
-
console.error('[ForgeLayer] Webhook received but no webhookSecret
|
|
409
|
+
console.error('[ForgeLayer] Webhook received but no webhookSecret configured. Call setupWebhook() first.');
|
|
418
410
|
return res.status(500).json({ ok: false, error: 'Webhook secret not configured.' });
|
|
419
411
|
}
|
|
420
412
|
|
|
421
413
|
const expected = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
|
|
422
|
-
|
|
414
|
+
// timingSafeEqual throws if buffer lengths differ, so check length first
|
|
415
|
+
const sigBuf = Buffer.from(sig);
|
|
416
|
+
const expBuf = Buffer.from(expected);
|
|
417
|
+
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
|
|
423
418
|
return res.status(401).json({ ok: false, error: 'Invalid signature.' });
|
|
424
419
|
}
|
|
425
420
|
|
|
426
421
|
let event;
|
|
427
|
-
try { event = JSON.parse(rawBody); }
|
|
428
|
-
|
|
429
|
-
}
|
|
422
|
+
try { event = JSON.parse(rawBody); }
|
|
423
|
+
catch (_) { return res.status(400).json({ ok: false, error: 'Invalid JSON body.' }); }
|
|
430
424
|
|
|
431
425
|
if (event.event === 'deposit_confirmed') {
|
|
432
426
|
const orderId = event.data?.orderId || event.data?.label || '';
|
|
433
|
-
const sessionKey =
|
|
434
|
-
const order =
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
427
|
+
const sessionKey = toSessionKey(orderId);
|
|
428
|
+
const order = await storage.getOrder(sessionKey);
|
|
429
|
+
|
|
430
|
+
if (order && order.status !== 'confirmed') {
|
|
431
|
+
const now = Math.floor(Date.now() / 1000);
|
|
432
|
+
const graceEndsAt = order.expiresAt + gracePeriodSeconds;
|
|
433
|
+
|
|
434
|
+
// Accept payment if within the payment window OR within the grace period
|
|
435
|
+
if (now < graceEndsAt) {
|
|
436
|
+
await markConfirmed(sessionKey, order);
|
|
437
|
+
} else {
|
|
438
|
+
console.warn('[ForgeLayer] Late payment received for order ' + orderId + ' but grace period has ended.');
|
|
439
439
|
}
|
|
440
440
|
}
|
|
441
441
|
}
|
|
442
442
|
|
|
443
443
|
if (onWebhookEvent) {
|
|
444
|
-
onWebhookEvent(event.event, event.data)
|
|
444
|
+
onWebhookEvent(event.event, event.data)
|
|
445
|
+
.catch(e => console.error('[ForgeLayer] onWebhookEvent error:', e));
|
|
445
446
|
}
|
|
446
447
|
|
|
447
448
|
return res.status(200).json({ ok: true });
|
|
448
449
|
}
|
|
449
450
|
|
|
450
|
-
// ──
|
|
451
|
+
// ── Webhook registration ──────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
async function setupWebhook(webhookUrl, confirmations) {
|
|
454
|
+
if (!webhookUrl || typeof webhookUrl !== 'string') throw new Error('setupWebhook: webhookUrl is required.');
|
|
455
|
+
confirmations = Math.max(1, +(confirmations || 1));
|
|
456
|
+
|
|
457
|
+
const newSecret = crypto.randomBytes(32).toString('hex');
|
|
458
|
+
|
|
459
|
+
const oldId = loadSavedWebhookId();
|
|
460
|
+
if (oldId) {
|
|
461
|
+
try { await flRequest('DELETE', '/webhooks/' + encodeURIComponent(oldId), apiKey); }
|
|
462
|
+
catch (_) {}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const data = await flRequest('POST', '/webhooks', apiKey, {
|
|
466
|
+
url: webhookUrl, secret: newSecret, events: ['deposit_confirmed'], confirmations,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const webhookId = data.id || data.webhookId || '';
|
|
470
|
+
saveWebhookSecret(newSecret);
|
|
471
|
+
if (webhookId) saveWebhookId(webhookId);
|
|
472
|
+
webhookSecret = newSecret;
|
|
473
|
+
|
|
474
|
+
return { webhookId, webhookUrl, confirmations };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Express middleware ────────────────────────────────────────────────────────
|
|
451
478
|
|
|
452
479
|
function middleware() {
|
|
453
480
|
return function forgeLayerMiddleware(req, res, next) {
|
|
454
|
-
// Determine the path relative to where this middleware is mounted
|
|
455
481
|
const mountPath = req.baseUrl || '';
|
|
456
482
|
const urlPath = (req.path || '/').replace(/\/$/, '') || '/';
|
|
457
483
|
|
|
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
|
-
}
|
|
484
|
+
if (urlPath === '/checkout.js' && req.method === 'GET') return serveBrowserScript(req, res, mountPath);
|
|
485
|
+
if (urlPath === '/create' && req.method === 'POST') return handleCreate(req, res).catch(next);
|
|
486
|
+
if (urlPath === '/status' && req.method === 'GET') return handleStatus(req, res).catch(next);
|
|
487
|
+
if (urlPath === '/webhook' && req.method === 'POST') return handleWebhook(req, res).catch(next);
|
|
470
488
|
return next();
|
|
471
489
|
};
|
|
472
490
|
}
|
|
@@ -474,11 +492,10 @@ function createCheckout(config) {
|
|
|
474
492
|
return { middleware, setupWebhook, handleCreate, handleStatus, handleWebhook, serveBrowserScript };
|
|
475
493
|
}
|
|
476
494
|
|
|
477
|
-
// ── Helpers
|
|
495
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
478
496
|
|
|
479
497
|
function readBody(req) {
|
|
480
498
|
return new Promise(function (resolve, reject) {
|
|
481
|
-
// If Express (or body-parser) already consumed the body, return it as a raw Buffer
|
|
482
499
|
if (req.body !== undefined) {
|
|
483
500
|
const raw = typeof req.body === 'string'
|
|
484
501
|
? req.body
|
|
@@ -486,29 +503,28 @@ function readBody(req) {
|
|
|
486
503
|
return resolve(raw);
|
|
487
504
|
}
|
|
488
505
|
const chunks = [];
|
|
489
|
-
req.on('data',
|
|
490
|
-
req.on('end',
|
|
506
|
+
req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
507
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
491
508
|
req.on('error', reject);
|
|
492
509
|
});
|
|
493
510
|
}
|
|
494
511
|
|
|
495
|
-
// ── Webhook secret/ID
|
|
512
|
+
// ── Webhook secret/ID persistence ─────────────────────────────────────────────
|
|
496
513
|
|
|
497
|
-
|
|
498
|
-
|
|
514
|
+
// Use process.cwd() so the file lands in the developer's project root,
|
|
515
|
+
// not inside node_modules/forgelayer-node/ where it gets wiped on reinstall.
|
|
516
|
+
const SECRET_FILE = path.join(process.cwd(), '.fl_webhook_secret');
|
|
517
|
+
const ID_FILE = path.join(process.cwd(), '.fl_webhook_id');
|
|
499
518
|
|
|
500
519
|
function loadSavedWebhookSecret() {
|
|
501
520
|
try { return fs.readFileSync(SECRET_FILE, 'utf8').trim(); } catch (_) { return ''; }
|
|
502
521
|
}
|
|
503
|
-
|
|
504
522
|
function saveWebhookSecret(secret) {
|
|
505
523
|
fs.writeFileSync(SECRET_FILE, secret, { encoding: 'utf8', mode: 0o600 });
|
|
506
524
|
}
|
|
507
|
-
|
|
508
525
|
function loadSavedWebhookId() {
|
|
509
526
|
try { return fs.readFileSync(ID_FILE, 'utf8').trim(); } catch (_) { return ''; }
|
|
510
527
|
}
|
|
511
|
-
|
|
512
528
|
function saveWebhookId(id) {
|
|
513
529
|
fs.writeFileSync(ID_FILE, id, 'utf8');
|
|
514
530
|
}
|