paybridge 0.8.0 → 0.10.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 +42 -0
- package/dist/cli/commands/drift-watch.d.ts +1 -0
- package/dist/cli/commands/drift-watch.js +118 -0
- package/dist/cli/commands/drift.d.ts +20 -0
- package/dist/cli/commands/drift.js +212 -0
- package/dist/cli/drift-store.d.ts +14 -0
- package/dist/cli/drift-store.js +80 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/runners.js +49 -11
- package/dist/cli/utils.js +14 -0
- package/dist/drift-detector.d.ts +40 -0
- package/dist/drift-detector.js +103 -0
- package/dist/providers/square.js +1 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
[](https://www.typescriptlang.org/)
|
|
9
9
|
[](https://discord.gg/Y2jCXNGgE)
|
|
10
10
|
|
|
11
|
+
📚 **[Documentation](https://kobie3717.github.io/paybridge/)** — full guides, provider reference, CLI docs, migration guides, examples.
|
|
12
|
+
|
|
11
13
|
Unified payment SDK for Node.js that works with multiple payment providers through a single, consistent API. Focus on South African providers first, with support for international gateways.
|
|
12
14
|
|
|
13
15
|
**WaSP is to WhatsApp what PayBridge is to payments** — one SDK, multiple backends, zero friction.
|
|
@@ -59,6 +61,46 @@ Runnable integrations for common Node.js frameworks:
|
|
|
59
61
|
|
|
60
62
|
Each example uses `PayBridgeRouter` with Stripe + PayStack and demonstrates webhook signature verification, idempotency, and provider-specific routing.
|
|
61
63
|
|
|
64
|
+
## Drift Detection
|
|
65
|
+
|
|
66
|
+
Payment providers change their APIs without notice. A field gets renamed, an endpoint moves, a type changes from `string` to `number`. Your integration silently breaks.
|
|
67
|
+
|
|
68
|
+
PayBridge includes **drift detection** — capture the shape of every provider's sandbox response, store it as a baseline, and get alerted the moment something changes.
|
|
69
|
+
|
|
70
|
+
### Quick Start
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Capture baselines (one-time setup)
|
|
74
|
+
npx paybridge drift-check --capture
|
|
75
|
+
|
|
76
|
+
# Check for drift
|
|
77
|
+
npx paybridge drift-check
|
|
78
|
+
|
|
79
|
+
# Watch continuously (6-hour interval)
|
|
80
|
+
npx paybridge drift-watch --interval 6h --webhook-url https://hooks.slack.com/...
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Example Output
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
=== Drift Detection ===
|
|
87
|
+
|
|
88
|
+
[✓] stripe — no drift
|
|
89
|
+
[⚠] mollie — drift detected:
|
|
90
|
+
+ new keys: data.expiresAt, _links.dashboard.href
|
|
91
|
+
- removed keys: data.metadata.legacy
|
|
92
|
+
! type changed: data.amount.value (string → number)
|
|
93
|
+
[⚠] square — drift detected:
|
|
94
|
+
+ new keys: payment_link.created_at_iso
|
|
95
|
+
[ ] paystack — Missing: PAYSTACK_API_KEY
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Exit code 1 if drift detected, 0 if clean. Perfect for CI/CD pipelines or cron jobs.
|
|
99
|
+
|
|
100
|
+
### Why It Matters
|
|
101
|
+
|
|
102
|
+
The Square `/checkout/payment-links → /online-checkout/payment-links` endpoint change would have shipped silently to production. With `drift-check` running daily, you get a Slack alert the moment it happens.
|
|
103
|
+
|
|
62
104
|
## Quick Start
|
|
63
105
|
|
|
64
106
|
> **Upgrading from 0.1 or 0.2?** See [docs/migration.md](docs/migration.md).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runDriftWatch(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runDriftWatch = runDriftWatch;
|
|
37
|
+
const path = __importStar(require("node:path"));
|
|
38
|
+
const runners_1 = require("../runners");
|
|
39
|
+
const drift_store_1 = require("../drift-store");
|
|
40
|
+
const drift_1 = require("./drift");
|
|
41
|
+
const utils_1 = require("../utils");
|
|
42
|
+
const INTERVALS = {
|
|
43
|
+
'30m': 30 * 60 * 1000,
|
|
44
|
+
'1h': 60 * 60 * 1000,
|
|
45
|
+
'6h': 6 * 60 * 60 * 1000,
|
|
46
|
+
'12h': 12 * 60 * 60 * 1000,
|
|
47
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
48
|
+
};
|
|
49
|
+
function parseInterval(str) {
|
|
50
|
+
const interval = INTERVALS[str];
|
|
51
|
+
if (!interval) {
|
|
52
|
+
throw new Error(`Invalid interval: ${str}. Supported: ${Object.keys(INTERVALS).join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
return interval;
|
|
55
|
+
}
|
|
56
|
+
async function runDriftWatch(args) {
|
|
57
|
+
const intervalArg = args.find((a, i) => args[i - 1] === '--interval') || '6h';
|
|
58
|
+
const interval = parseInterval(intervalArg);
|
|
59
|
+
const once = args.includes('--once');
|
|
60
|
+
const webhookUrl = args.find((a, i) => args[i - 1] === '--webhook-url');
|
|
61
|
+
const baselineDir = args.find((a, i) => args[i - 1] === '--baseline-dir') ||
|
|
62
|
+
path.join(process.cwd(), '.paybridge', 'drift-baseline');
|
|
63
|
+
const store = new drift_store_1.FileDriftStore(baselineDir);
|
|
64
|
+
let running = true;
|
|
65
|
+
const cleanup = () => {
|
|
66
|
+
running = false;
|
|
67
|
+
console.log('\nStopping drift watch...');
|
|
68
|
+
process.exit(0);
|
|
69
|
+
};
|
|
70
|
+
process.on('SIGTERM', cleanup);
|
|
71
|
+
process.on('SIGINT', cleanup);
|
|
72
|
+
const runCheck = async () => {
|
|
73
|
+
const timestamp = new Date().toISOString();
|
|
74
|
+
console.log(`\n${(0, utils_1.colorize)('[drift-watch]', 'cyan')} Running check at ${timestamp}\n`);
|
|
75
|
+
try {
|
|
76
|
+
const results = await (0, drift_1.runDriftCheck)(runners_1.runners, store, { webhookUrl });
|
|
77
|
+
const driftCount = results.filter((r) => r.status === 'drift').length;
|
|
78
|
+
const noDriftCount = results.filter((r) => r.status === 'no-drift').length;
|
|
79
|
+
const skippedCount = results.filter((r) => r.status === 'skipped').length;
|
|
80
|
+
const errorCount = results.filter((r) => r.status === 'error').length;
|
|
81
|
+
for (const res of results) {
|
|
82
|
+
if (res.status === 'drift' && res.report) {
|
|
83
|
+
console.log(`${(0, utils_1.colorize)('[⚠]', 'yellow')} ${res.provider} — drift detected`);
|
|
84
|
+
if (res.report.addedKeys.length > 0) {
|
|
85
|
+
console.log(` + new keys: ${res.report.addedKeys.join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
if (res.report.removedKeys.length > 0) {
|
|
88
|
+
console.log(` - removed keys: ${res.report.removedKeys.join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
if (res.report.typeChanges.length > 0) {
|
|
91
|
+
for (const change of res.report.typeChanges) {
|
|
92
|
+
console.log(` ! type changed: ${change.key} (${change.oldType} → ${change.newType})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (res.status === 'error') {
|
|
97
|
+
console.log(`${(0, utils_1.colorize)('[✗]', 'red')} ${res.provider} ERROR: ${res.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
console.log(`\n${(0, utils_1.colorize)('[summary]', 'cyan')} drift: ${driftCount}, clean: ${noDriftCount}, skipped: ${skippedCount}, errors: ${errorCount}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
console.error(`${(0, utils_1.colorize)('[!]', 'red')} Check failed: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
await runCheck();
|
|
107
|
+
if (once) {
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
console.log(`\n${(0, utils_1.colorize)('[drift-watch]', 'cyan')} Watching every ${intervalArg}. Press Ctrl+C to stop.\n`);
|
|
111
|
+
const timer = setInterval(() => {
|
|
112
|
+
if (running) {
|
|
113
|
+
runCheck();
|
|
114
|
+
}
|
|
115
|
+
}, interval);
|
|
116
|
+
timer.unref();
|
|
117
|
+
await new Promise(() => { });
|
|
118
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ProviderRunner } from '../runners';
|
|
2
|
+
import { DriftStore } from '../drift-store';
|
|
3
|
+
import { DriftReport } from '../../drift-detector';
|
|
4
|
+
interface DriftCheckOptions {
|
|
5
|
+
capture?: boolean;
|
|
6
|
+
baselineDir?: string;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
webhookUrl?: string;
|
|
9
|
+
providers?: string[];
|
|
10
|
+
}
|
|
11
|
+
interface DriftCheckResult {
|
|
12
|
+
provider: string;
|
|
13
|
+
status: 'captured' | 'no-baseline' | 'no-drift' | 'drift' | 'skipped' | 'error';
|
|
14
|
+
message?: string;
|
|
15
|
+
report?: DriftReport;
|
|
16
|
+
keyCount?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function runDriftCheck(providedRunners: ProviderRunner[], store: DriftStore, opts: DriftCheckOptions): Promise<DriftCheckResult[]>;
|
|
19
|
+
export declare function runDrift(args: string[]): Promise<void>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runDriftCheck = runDriftCheck;
|
|
37
|
+
exports.runDrift = runDrift;
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const runners_1 = require("../runners");
|
|
40
|
+
const drift_store_1 = require("../drift-store");
|
|
41
|
+
const drift_detector_1 = require("../../drift-detector");
|
|
42
|
+
const utils_1 = require("../utils");
|
|
43
|
+
async function postWebhook(url, payload) {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(payload),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error(`Webhook POST failed: ${res.status} ${res.statusText}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function runDriftCheck(providedRunners, store, opts) {
|
|
54
|
+
const libVersion = '0.10.0';
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const runner of providedRunners) {
|
|
57
|
+
const missing = runner.envRequired.filter((key) => !process.env[key]);
|
|
58
|
+
if (missing.length > 0) {
|
|
59
|
+
results.push({
|
|
60
|
+
provider: runner.name,
|
|
61
|
+
status: 'skipped',
|
|
62
|
+
message: `Missing: ${missing.join(', ')}`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const response = await runner.run();
|
|
68
|
+
const shape = (0, drift_detector_1.captureShape)(response);
|
|
69
|
+
shape.status = response.status;
|
|
70
|
+
if (opts.capture) {
|
|
71
|
+
const baseline = {
|
|
72
|
+
providerName: runner.name,
|
|
73
|
+
operation: 'createPayment',
|
|
74
|
+
shape,
|
|
75
|
+
libVersion,
|
|
76
|
+
};
|
|
77
|
+
await store.save(baseline);
|
|
78
|
+
results.push({
|
|
79
|
+
provider: runner.name,
|
|
80
|
+
status: 'captured',
|
|
81
|
+
keyCount: shape.keys.length,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const baseline = await store.load(runner.name);
|
|
86
|
+
if (!baseline) {
|
|
87
|
+
results.push({
|
|
88
|
+
provider: runner.name,
|
|
89
|
+
status: 'no-baseline',
|
|
90
|
+
message: 'No baseline found (run with --capture to create)',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const report = (0, drift_detector_1.diffBaseline)(baseline, shape, runner.name);
|
|
95
|
+
if (report.driftDetected) {
|
|
96
|
+
results.push({
|
|
97
|
+
provider: runner.name,
|
|
98
|
+
status: 'drift',
|
|
99
|
+
report,
|
|
100
|
+
});
|
|
101
|
+
if (opts.webhookUrl) {
|
|
102
|
+
try {
|
|
103
|
+
await postWebhook(opts.webhookUrl, { provider: runner.name, drift: report, libVersion });
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
console.error(`${(0, utils_1.colorize)('[!]', 'yellow')} ${runner.name} webhook failed: ${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
results.push({
|
|
112
|
+
provider: runner.name,
|
|
113
|
+
status: 'no-drift',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
results.push({
|
|
121
|
+
provider: runner.name,
|
|
122
|
+
status: 'error',
|
|
123
|
+
message: error.message || String(error),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
async function runDrift(args) {
|
|
130
|
+
const capture = args.includes('--capture');
|
|
131
|
+
const jsonOutput = args.includes('--json');
|
|
132
|
+
const baselineDir = args.find((a, i) => args[i - 1] === '--baseline-dir') ||
|
|
133
|
+
path.join(process.cwd(), '.paybridge', 'drift-baseline');
|
|
134
|
+
const webhookUrl = args.find((a, i) => args[i - 1] === '--webhook-url');
|
|
135
|
+
const providerNames = args.filter((a) => !a.startsWith('--') && a !== 'drift-check');
|
|
136
|
+
let selectedRunners = runners_1.runners;
|
|
137
|
+
if (providerNames.length > 0) {
|
|
138
|
+
selectedRunners = runners_1.runners.filter((r) => providerNames.includes(r.name));
|
|
139
|
+
const unknownProviders = providerNames.filter((p) => !runners_1.runners.find((r) => r.name === p));
|
|
140
|
+
if (unknownProviders.length > 0) {
|
|
141
|
+
console.error(`Unknown providers: ${unknownProviders.join(', ')}`);
|
|
142
|
+
console.error(`Available: ${runners_1.runners.map((r) => r.name).join(', ')}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const store = new drift_store_1.FileDriftStore(baselineDir);
|
|
147
|
+
const results = await runDriftCheck(selectedRunners, store, { capture, baselineDir, json: jsonOutput, webhookUrl });
|
|
148
|
+
if (jsonOutput) {
|
|
149
|
+
console.log(JSON.stringify(results, null, 2));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
if (capture) {
|
|
153
|
+
console.log('\n=== Drift Baseline Capture ===\n');
|
|
154
|
+
for (const res of results) {
|
|
155
|
+
if (res.status === 'captured') {
|
|
156
|
+
console.log(`${(0, utils_1.colorize)('[saved]', 'green')} ${res.provider} baseline (${res.keyCount} keys)`);
|
|
157
|
+
}
|
|
158
|
+
else if (res.status === 'skipped') {
|
|
159
|
+
console.log(`${(0, utils_1.colorize)('[ ]', 'dim')} ${res.provider} — ${res.message}`);
|
|
160
|
+
}
|
|
161
|
+
else if (res.status === 'error') {
|
|
162
|
+
console.log(`${(0, utils_1.colorize)('[✗]', 'red')} ${res.provider} ERROR: ${res.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.log('\n=== Drift Detection ===\n');
|
|
168
|
+
for (const res of results) {
|
|
169
|
+
if (res.status === 'no-drift') {
|
|
170
|
+
console.log(`${(0, utils_1.colorize)('[✓]', 'green')} ${res.provider} — no drift`);
|
|
171
|
+
}
|
|
172
|
+
else if (res.status === 'no-baseline') {
|
|
173
|
+
console.log(`${(0, utils_1.colorize)('[ ]', 'dim')} ${res.provider} — ${res.message}`);
|
|
174
|
+
}
|
|
175
|
+
else if (res.status === 'skipped') {
|
|
176
|
+
console.log(`${(0, utils_1.colorize)('[ ]', 'dim')} ${res.provider} — ${res.message}`);
|
|
177
|
+
}
|
|
178
|
+
else if (res.status === 'drift' && res.report) {
|
|
179
|
+
console.log(`${(0, utils_1.colorize)('[⚠]', 'yellow')} ${res.provider} — drift detected:`);
|
|
180
|
+
if (res.report.addedKeys.length > 0) {
|
|
181
|
+
console.log(` ${(0, utils_1.colorize)('+', 'green')} new keys: ${res.report.addedKeys.join(', ')}`);
|
|
182
|
+
}
|
|
183
|
+
if (res.report.removedKeys.length > 0) {
|
|
184
|
+
console.log(` ${(0, utils_1.colorize)('-', 'red')} removed keys: ${res.report.removedKeys.join(', ')}`);
|
|
185
|
+
}
|
|
186
|
+
if (res.report.typeChanges.length > 0) {
|
|
187
|
+
for (const change of res.report.typeChanges) {
|
|
188
|
+
console.log(` ${(0, utils_1.colorize)('!', 'yellow')} type changed: ${change.key} (${change.oldType} → ${change.newType})`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (res.report.statusChanged) {
|
|
192
|
+
console.log(` ${(0, utils_1.colorize)('!', 'yellow')} status changed: ${res.report.statusChanged.old} → ${res.report.statusChanged.new}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (res.status === 'error') {
|
|
196
|
+
console.log(`${(0, utils_1.colorize)('[✗]', 'red')} ${res.provider} ERROR: ${res.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const driftCount = results.filter((r) => r.status === 'drift').length;
|
|
202
|
+
const errorCount = results.filter((r) => r.status === 'error').length;
|
|
203
|
+
if (errorCount > 0) {
|
|
204
|
+
process.exit(2);
|
|
205
|
+
}
|
|
206
|
+
else if (driftCount > 0) {
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ProviderBaseline } from '../drift-detector';
|
|
2
|
+
export interface DriftStore {
|
|
3
|
+
load(providerName: string): Promise<ProviderBaseline | null>;
|
|
4
|
+
save(baseline: ProviderBaseline): Promise<void>;
|
|
5
|
+
listProviders(): Promise<string[]>;
|
|
6
|
+
}
|
|
7
|
+
export declare class FileDriftStore implements DriftStore {
|
|
8
|
+
private dir;
|
|
9
|
+
constructor(dir: string);
|
|
10
|
+
private getFilePath;
|
|
11
|
+
load(providerName: string): Promise<ProviderBaseline | null>;
|
|
12
|
+
save(baseline: ProviderBaseline): Promise<void>;
|
|
13
|
+
listProviders(): Promise<string[]>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FileDriftStore = void 0;
|
|
37
|
+
const node_fs_1 = require("node:fs");
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
class FileDriftStore {
|
|
40
|
+
constructor(dir) {
|
|
41
|
+
this.dir = dir;
|
|
42
|
+
}
|
|
43
|
+
getFilePath(providerName) {
|
|
44
|
+
return path.join(this.dir, `${providerName}.json`);
|
|
45
|
+
}
|
|
46
|
+
async load(providerName) {
|
|
47
|
+
const filePath = this.getFilePath(providerName);
|
|
48
|
+
try {
|
|
49
|
+
const content = await node_fs_1.promises.readFile(filePath, 'utf-8');
|
|
50
|
+
return JSON.parse(content);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err.code === 'ENOENT') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async save(baseline) {
|
|
60
|
+
await node_fs_1.promises.mkdir(this.dir, { recursive: true });
|
|
61
|
+
const filePath = this.getFilePath(baseline.providerName);
|
|
62
|
+
const content = JSON.stringify(baseline, null, 2);
|
|
63
|
+
await node_fs_1.promises.writeFile(filePath, content, 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
async listProviders() {
|
|
66
|
+
try {
|
|
67
|
+
const files = await node_fs_1.promises.readdir(this.dir);
|
|
68
|
+
return files
|
|
69
|
+
.filter((f) => f.endsWith('.json'))
|
|
70
|
+
.map((f) => f.slice(0, -5));
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err.code === 'ENOENT') {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
exports.FileDriftStore = FileDriftStore;
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,8 @@ const test_1 = require("./commands/test");
|
|
|
5
5
|
const providers_1 = require("./commands/providers");
|
|
6
6
|
const webhook_1 = require("./commands/webhook");
|
|
7
7
|
const quote_1 = require("./commands/quote");
|
|
8
|
+
const drift_1 = require("./commands/drift");
|
|
9
|
+
const drift_watch_1 = require("./commands/drift-watch");
|
|
8
10
|
const utils_1 = require("./utils");
|
|
9
11
|
async function main() {
|
|
10
12
|
const [, , command, ...args] = process.argv;
|
|
@@ -21,6 +23,12 @@ async function main() {
|
|
|
21
23
|
case 'quote':
|
|
22
24
|
await (0, quote_1.runQuote)(args);
|
|
23
25
|
break;
|
|
26
|
+
case 'drift-check':
|
|
27
|
+
await (0, drift_1.runDrift)(args);
|
|
28
|
+
break;
|
|
29
|
+
case 'drift-watch':
|
|
30
|
+
await (0, drift_watch_1.runDriftWatch)(args);
|
|
31
|
+
break;
|
|
24
32
|
case '-h':
|
|
25
33
|
case '--help':
|
|
26
34
|
case 'help':
|
package/dist/cli/runners.js
CHANGED
|
@@ -6,7 +6,7 @@ const crypto_1 = require("../crypto");
|
|
|
6
6
|
const timestamp = Date.now();
|
|
7
7
|
const testCustomer = {
|
|
8
8
|
name: 'Test User',
|
|
9
|
-
email: '
|
|
9
|
+
email: 'paybridge-sandbox@gmail.com',
|
|
10
10
|
phone: '0825551234',
|
|
11
11
|
};
|
|
12
12
|
const testUrls = {
|
|
@@ -111,14 +111,26 @@ exports.runners = [
|
|
|
111
111
|
credentials: { apiKey: process.env.PAYSTACK_API_KEY },
|
|
112
112
|
sandbox: true,
|
|
113
113
|
});
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
const currencies = (process.env.PAYSTACK_TEST_CURRENCY || 'NGN,ZAR,GHS,KES').split(',');
|
|
115
|
+
let lastErr;
|
|
116
|
+
for (const currency of currencies) {
|
|
117
|
+
try {
|
|
118
|
+
const payment = await pay.createPayment({
|
|
119
|
+
amount: 100.0,
|
|
120
|
+
currency: currency.trim(),
|
|
121
|
+
reference: `cli-test-${timestamp}-${currency.trim()}`,
|
|
122
|
+
customer: testCustomer,
|
|
123
|
+
urls: testUrls,
|
|
124
|
+
});
|
|
125
|
+
return { id: payment.id, checkoutUrl: payment.checkoutUrl, status: payment.status };
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
lastErr = e;
|
|
129
|
+
if (!/currency not supported/i.test(e.message))
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw lastErr ?? new Error('No currency accepted by PayStack merchant');
|
|
122
134
|
},
|
|
123
135
|
},
|
|
124
136
|
{
|
|
@@ -289,11 +301,37 @@ exports.runners = [
|
|
|
289
301
|
name: 'pesapal',
|
|
290
302
|
envRequired: ['PESAPAL_CONSUMER_KEY', 'PESAPAL_CONSUMER_SECRET'],
|
|
291
303
|
run: async () => {
|
|
304
|
+
const consumerKey = process.env.PESAPAL_CONSUMER_KEY;
|
|
305
|
+
const consumerSecret = process.env.PESAPAL_CONSUMER_SECRET;
|
|
306
|
+
let notificationId = process.env.PESAPAL_NOTIFICATION_ID;
|
|
307
|
+
if (!notificationId) {
|
|
308
|
+
const tokenRes = await fetch('https://cybqa.pesapal.com/pesapalv3/api/Auth/RequestToken', {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
311
|
+
body: JSON.stringify({ consumer_key: consumerKey, consumer_secret: consumerSecret }),
|
|
312
|
+
});
|
|
313
|
+
const tokenJson = await tokenRes.json();
|
|
314
|
+
const ipnRes = await fetch('https://cybqa.pesapal.com/pesapalv3/api/URLSetup/RegisterIPN', {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: {
|
|
317
|
+
'Content-Type': 'application/json',
|
|
318
|
+
Accept: 'application/json',
|
|
319
|
+
Authorization: `Bearer ${tokenJson.token}`,
|
|
320
|
+
},
|
|
321
|
+
body: JSON.stringify({
|
|
322
|
+
url: 'https://example.com/pesapal-ipn',
|
|
323
|
+
ipn_notification_type: 'GET',
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
const ipnJson = await ipnRes.json();
|
|
327
|
+
notificationId = ipnJson.ipn_id;
|
|
328
|
+
}
|
|
292
329
|
const pay = new index_1.PayBridge({
|
|
293
330
|
provider: 'pesapal',
|
|
294
331
|
credentials: {
|
|
295
|
-
apiKey:
|
|
296
|
-
secretKey:
|
|
332
|
+
apiKey: consumerKey,
|
|
333
|
+
secretKey: consumerSecret,
|
|
334
|
+
notificationId,
|
|
297
335
|
},
|
|
298
336
|
sandbox: true,
|
|
299
337
|
});
|
package/dist/cli/utils.js
CHANGED
|
@@ -70,9 +70,20 @@ COMMANDS
|
|
|
70
70
|
webhook verify <p> Verify webhook signature (raw body from stdin)
|
|
71
71
|
webhook parse <p> Parse webhook event (raw body from stdin)
|
|
72
72
|
quote <p> [opts] Get a crypto on/off-ramp quote
|
|
73
|
+
drift-check [opts] Capture/compare provider response shapes (drift detection)
|
|
74
|
+
drift-watch [opts] Run drift-check on a loop (long-running monitor)
|
|
73
75
|
help, -h, --help Print this help
|
|
74
76
|
version, -v Print version
|
|
75
77
|
|
|
78
|
+
DRIFT DETECTION
|
|
79
|
+
drift-check [provider] Check all/single provider for API drift
|
|
80
|
+
drift-check --capture Capture baseline snapshots (init mode)
|
|
81
|
+
drift-check --json Output JSON instead of human-readable
|
|
82
|
+
drift-check --baseline-dir <p> Custom baseline location (default: .paybridge/drift-baseline)
|
|
83
|
+
drift-check --webhook-url <url> POST drift report to URL on detection
|
|
84
|
+
drift-watch --interval <6h|1h> Run every interval (default: 6h)
|
|
85
|
+
drift-watch --once Alias for drift-check (don't loop)
|
|
86
|
+
|
|
76
87
|
PROVIDER ENV VARS
|
|
77
88
|
See SETUP.md or run 'paybridge test --all' for the full list.
|
|
78
89
|
|
|
@@ -82,6 +93,9 @@ EXAMPLES
|
|
|
82
93
|
STRIPE_API_KEY=sk_test_... paybridge test stripe
|
|
83
94
|
echo '{"id":"evt_x"}' | paybridge webhook parse paystack \\
|
|
84
95
|
--header x-paystack-signature=abc
|
|
96
|
+
paybridge drift-check --capture
|
|
97
|
+
paybridge drift-check stripe
|
|
98
|
+
paybridge drift-watch --interval 1h --webhook-url https://hooks.slack.com/...
|
|
85
99
|
|
|
86
100
|
Docs: https://github.com/kobie3717/paybridge
|
|
87
101
|
`.trim();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface ResponseShape {
|
|
2
|
+
keys: string[];
|
|
3
|
+
types: Record<string, 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'>;
|
|
4
|
+
status?: string;
|
|
5
|
+
capturedAt: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ProviderBaseline {
|
|
8
|
+
providerName: string;
|
|
9
|
+
operation: string;
|
|
10
|
+
shape: ResponseShape;
|
|
11
|
+
libVersion: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DriftReport {
|
|
14
|
+
providerName: string;
|
|
15
|
+
driftDetected: boolean;
|
|
16
|
+
addedKeys: string[];
|
|
17
|
+
removedKeys: string[];
|
|
18
|
+
typeChanges: Array<{
|
|
19
|
+
key: string;
|
|
20
|
+
oldType: string;
|
|
21
|
+
newType: string;
|
|
22
|
+
}>;
|
|
23
|
+
statusChanged?: {
|
|
24
|
+
old: string;
|
|
25
|
+
new: string;
|
|
26
|
+
};
|
|
27
|
+
baselineCapturedAt: string;
|
|
28
|
+
newCapturedAt: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function captureShape(response: unknown): ResponseShape;
|
|
31
|
+
export declare function compareShapes(baseline: ResponseShape, current: ResponseShape): {
|
|
32
|
+
addedKeys: string[];
|
|
33
|
+
removedKeys: string[];
|
|
34
|
+
typeChanges: Array<{
|
|
35
|
+
key: string;
|
|
36
|
+
oldType: string;
|
|
37
|
+
newType: string;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
export declare function diffBaseline(baseline: ProviderBaseline, currentShape: ResponseShape, providerName: string): DriftReport;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.captureShape = captureShape;
|
|
4
|
+
exports.compareShapes = compareShapes;
|
|
5
|
+
exports.diffBaseline = diffBaseline;
|
|
6
|
+
function getType(value) {
|
|
7
|
+
if (value === null)
|
|
8
|
+
return 'null';
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return 'array';
|
|
11
|
+
return typeof value;
|
|
12
|
+
}
|
|
13
|
+
function flattenKeys(obj, prefix = '') {
|
|
14
|
+
const result = [];
|
|
15
|
+
if (obj === null || obj === undefined) {
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(obj)) {
|
|
19
|
+
if (obj.length === 0) {
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
for (const item of obj) {
|
|
23
|
+
const nested = flattenKeys(item, `${prefix}[*]`);
|
|
24
|
+
result.push(...nested);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (typeof obj === 'object') {
|
|
28
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
29
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
30
|
+
const valueType = getType(value);
|
|
31
|
+
if (valueType === 'object' || valueType === 'array') {
|
|
32
|
+
const nested = flattenKeys(value, path);
|
|
33
|
+
result.push(...nested);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
result.push({ path, type: valueType });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function deduplicateKeys(entries) {
|
|
43
|
+
const seen = new Map();
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (!seen.has(entry.path)) {
|
|
46
|
+
seen.set(entry.path, entry.type);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Array.from(seen.entries()).map(([path, type]) => ({ path, type }));
|
|
50
|
+
}
|
|
51
|
+
function captureShape(response) {
|
|
52
|
+
const entries = flattenKeys(response);
|
|
53
|
+
const deduplicated = deduplicateKeys(entries);
|
|
54
|
+
const sorted = deduplicated.sort((a, b) => a.path.localeCompare(b.path));
|
|
55
|
+
const keys = sorted.map((e) => e.path);
|
|
56
|
+
const types = {};
|
|
57
|
+
for (const entry of sorted) {
|
|
58
|
+
types[entry.path] = entry.type;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
keys,
|
|
62
|
+
types,
|
|
63
|
+
capturedAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function compareShapes(baseline, current) {
|
|
67
|
+
const baselineSet = new Set(baseline.keys);
|
|
68
|
+
const currentSet = new Set(current.keys);
|
|
69
|
+
const addedKeys = current.keys.filter((k) => !baselineSet.has(k));
|
|
70
|
+
const removedKeys = baseline.keys.filter((k) => !currentSet.has(k));
|
|
71
|
+
const typeChanges = [];
|
|
72
|
+
for (const key of current.keys) {
|
|
73
|
+
if (baselineSet.has(key)) {
|
|
74
|
+
const oldType = baseline.types[key];
|
|
75
|
+
const newType = current.types[key];
|
|
76
|
+
if (oldType !== newType) {
|
|
77
|
+
typeChanges.push({ key, oldType, newType });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { addedKeys, removedKeys, typeChanges };
|
|
82
|
+
}
|
|
83
|
+
function diffBaseline(baseline, currentShape, providerName) {
|
|
84
|
+
const diff = compareShapes(baseline.shape, currentShape);
|
|
85
|
+
let statusChanged;
|
|
86
|
+
if (baseline.shape.status && currentShape.status && baseline.shape.status !== currentShape.status) {
|
|
87
|
+
statusChanged = { old: baseline.shape.status, new: currentShape.status };
|
|
88
|
+
}
|
|
89
|
+
const driftDetected = diff.addedKeys.length > 0 ||
|
|
90
|
+
diff.removedKeys.length > 0 ||
|
|
91
|
+
diff.typeChanges.length > 0 ||
|
|
92
|
+
!!statusChanged;
|
|
93
|
+
return {
|
|
94
|
+
providerName,
|
|
95
|
+
driftDetected,
|
|
96
|
+
addedKeys: diff.addedKeys,
|
|
97
|
+
removedKeys: diff.removedKeys,
|
|
98
|
+
typeChanges: diff.typeChanges,
|
|
99
|
+
statusChanged,
|
|
100
|
+
baselineCapturedAt: baseline.shape.capturedAt,
|
|
101
|
+
newCapturedAt: currentShape.capturedAt,
|
|
102
|
+
};
|
|
103
|
+
}
|
package/dist/providers/square.js
CHANGED
|
@@ -91,7 +91,7 @@ class SquareProvider extends base_1.PaymentProvider {
|
|
|
91
91
|
buyer_email: params.customer.email,
|
|
92
92
|
},
|
|
93
93
|
};
|
|
94
|
-
const response = await this.apiRequest('POST', '/checkout/payment-links', requestBody);
|
|
94
|
+
const response = await this.apiRequest('POST', '/online-checkout/payment-links', requestBody);
|
|
95
95
|
const link = response.payment_link;
|
|
96
96
|
return {
|
|
97
97
|
id: link.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paybridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "One API for fiat + crypto payments. Multi-provider routing, automatic failover, MoonPay on/off-ramp. SA-first, global-ready.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"cli": "node dist/cli/index.js",
|
|
15
15
|
"prepack": "npm run clean && npm run build:cli",
|
|
16
16
|
"prepublishOnly": "npm run clean && npm run build:cli",
|
|
17
|
-
"test": "tsc && tsc --project tsconfig.test.json &&
|
|
17
|
+
"test": "tsc && tsc --project tsconfig.test.json && find dist-test -name '*.test.js' -exec node --test {} +",
|
|
18
18
|
"test:e2e:moonpay": "tsx tests/e2e/moonpay-sandbox.ts",
|
|
19
19
|
"test:e2e:yellowcard": "tsx tests/e2e/yellowcard-sandbox.ts",
|
|
20
20
|
"test:e2e:sandbox": "tsx tests/e2e/sandbox-validate.ts"
|
|
@@ -46,7 +46,10 @@
|
|
|
46
46
|
"ramp",
|
|
47
47
|
"east-africa",
|
|
48
48
|
"cli",
|
|
49
|
-
"command-line"
|
|
49
|
+
"command-line",
|
|
50
|
+
"drift-detection",
|
|
51
|
+
"monitoring",
|
|
52
|
+
"api-validation"
|
|
50
53
|
],
|
|
51
54
|
"author": "Kobie Wentzel",
|
|
52
55
|
"license": "MIT",
|