site-agent-pro 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +689 -0
- package/dist/auth/credentialStore.js +62 -0
- package/dist/auth/inbox.js +193 -0
- package/dist/auth/profile.js +379 -0
- package/dist/auth/runner.js +1124 -0
- package/dist/backend/dashboardData.js +194 -0
- package/dist/backend/runArtifacts.js +48 -0
- package/dist/backend/runRepository.js +93 -0
- package/dist/bin.js +2 -0
- package/dist/cli/backfillSiteChecks.js +143 -0
- package/dist/cli/run.js +309 -0
- package/dist/cli/trade.js +69 -0
- package/dist/config.js +199 -0
- package/dist/core/agentProfiles.js +55 -0
- package/dist/core/aggregateReport.js +382 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/customTaskSuite.js +148 -0
- package/dist/core/evaluator.js +217 -0
- package/dist/core/executor.js +788 -0
- package/dist/core/fallbackReport.js +335 -0
- package/dist/core/formHeuristics.js +411 -0
- package/dist/core/gameplaySummary.js +164 -0
- package/dist/core/interaction.js +202 -0
- package/dist/core/pageState.js +201 -0
- package/dist/core/planner.js +1669 -0
- package/dist/core/processSubmissionBatch.js +204 -0
- package/dist/core/runAuditJob.js +170 -0
- package/dist/core/runner.js +2352 -0
- package/dist/core/siteBrief.js +107 -0
- package/dist/core/siteChecks.js +1526 -0
- package/dist/core/taskDirectives.js +279 -0
- package/dist/core/taskHeuristics.js +263 -0
- package/dist/dashboard/client.js +1256 -0
- package/dist/dashboard/contracts.js +95 -0
- package/dist/dashboard/narrative.js +277 -0
- package/dist/dashboard/server.js +458 -0
- package/dist/dashboard/theme.js +888 -0
- package/dist/index.js +84 -0
- package/dist/llm/client.js +188 -0
- package/dist/paystack/account.js +123 -0
- package/dist/paystack/client.js +100 -0
- package/dist/paystack/index.js +13 -0
- package/dist/paystack/test-paystack.js +83 -0
- package/dist/paystack/transfer.js +138 -0
- package/dist/paystack/types.js +74 -0
- package/dist/paystack/webhook.js +121 -0
- package/dist/prompts/browserAgent.js +124 -0
- package/dist/prompts/reviewer.js +71 -0
- package/dist/reporting/clickReplay.js +290 -0
- package/dist/reporting/html.js +930 -0
- package/dist/reporting/markdown.js +238 -0
- package/dist/reporting/template.js +1141 -0
- package/dist/schemas/types.js +361 -0
- package/dist/submissions/customTasks.js +196 -0
- package/dist/submissions/html.js +770 -0
- package/dist/submissions/model.js +56 -0
- package/dist/submissions/publicUrl.js +76 -0
- package/dist/submissions/service.js +74 -0
- package/dist/submissions/store.js +37 -0
- package/dist/submissions/types.js +65 -0
- package/dist/trade/engine.js +241 -0
- package/dist/trade/evm/erc20.js +44 -0
- package/dist/trade/extractor.js +148 -0
- package/dist/trade/policy.js +35 -0
- package/dist/trade/session.js +31 -0
- package/dist/trade/types.js +107 -0
- package/dist/trade/validator.js +148 -0
- package/dist/utils/files.js +59 -0
- package/dist/utils/log.js +24 -0
- package/dist/utils/playwrightCompat.js +14 -0
- package/dist/utils/time.js +3 -0
- package/dist/wallet/provider.js +345 -0
- package/dist/wallet/relay.js +129 -0
- package/dist/wallet/wallet.js +178 -0
- package/docs/01-installation.md +134 -0
- package/docs/02-running-your-first-audit.md +136 -0
- package/docs/03-configuration.md +233 -0
- package/docs/04-how-the-agent-thinks.md +41 -0
- package/docs/05-extending-personas-and-tasks.md +42 -0
- package/docs/06-hardening-for-production.md +92 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* site-agent-pro — programmatic API
|
|
3
|
+
*
|
|
4
|
+
* Use `runAudit()` for the simplest integration:
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { runAudit } from "site-agent-pro";
|
|
9
|
+
*
|
|
10
|
+
* const result = await runAudit({
|
|
11
|
+
* url: "http://localhost:3000",
|
|
12
|
+
* tasks: [
|
|
13
|
+
* "Open the pricing page and note the visible plans",
|
|
14
|
+
* "Click the sign-up button and check the form fields",
|
|
15
|
+
* ],
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* console.log(`Score: ${result.report.overall_score}/10`);
|
|
19
|
+
* console.log(`Report saved to: ${result.runDir}`);
|
|
20
|
+
*
|
|
21
|
+
* // Fail CI if quality drops below your threshold
|
|
22
|
+
* if (result.report.overall_score < 7) {
|
|
23
|
+
* process.exit(1);
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Use `runAuditJob()` directly for full control over task suites
|
|
28
|
+
* and internal configuration.
|
|
29
|
+
*/
|
|
30
|
+
import { buildCustomTaskSuite } from "./core/customTaskSuite.js";
|
|
31
|
+
import { runAuditJob } from "./core/runAuditJob.js";
|
|
32
|
+
import { normalizeCustomTasks } from "./submissions/customTasks.js";
|
|
33
|
+
// ─── Public lower-level exports ────────────────────────────────────────────
|
|
34
|
+
export { runAuditJob } from "./core/runAuditJob.js";
|
|
35
|
+
export { buildCustomTaskSuite } from "./core/customTaskSuite.js";
|
|
36
|
+
export { normalizeCustomTasks } from "./submissions/customTasks.js";
|
|
37
|
+
/**
|
|
38
|
+
* Run an Site Agent Pro audit programmatically against any URL,
|
|
39
|
+
* including localhost dev servers.
|
|
40
|
+
*
|
|
41
|
+
* Returns the full run result including the scored report,
|
|
42
|
+
* the run directory where artifacts were saved, and the
|
|
43
|
+
* raw task execution data.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* // package.json devDependency usage:
|
|
48
|
+
* // "site-agent-pro": "^1.0.0"
|
|
49
|
+
*
|
|
50
|
+
* import { runAudit } from "site-agent-pro";
|
|
51
|
+
*
|
|
52
|
+
* const result = await runAudit({
|
|
53
|
+
* url: "http://localhost:3000",
|
|
54
|
+
* tasks: ["Check the homepage CTA", "Try the signup flow"],
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* console.log(`Score: ${result.report.overall_score}/10`);
|
|
58
|
+
*
|
|
59
|
+
* // Gate your CI pipeline on audit score
|
|
60
|
+
* if (result.report.overall_score < 7) {
|
|
61
|
+
* process.exit(1);
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export async function runAudit(options) {
|
|
66
|
+
const normalizedTasks = normalizeCustomTasks(options.tasks);
|
|
67
|
+
const suite = buildCustomTaskSuite(normalizedTasks);
|
|
68
|
+
return runAuditJob({
|
|
69
|
+
baseUrl: options.url,
|
|
70
|
+
suiteOverride: suite,
|
|
71
|
+
...(options.headed !== undefined ? { headed: options.headed } : {}),
|
|
72
|
+
...(options.mobile !== undefined ? { mobile: options.mobile } : {}),
|
|
73
|
+
...(options.ignoreHttpsErrors !== undefined ? { ignoreHttpsErrors: options.ignoreHttpsErrors } : {}),
|
|
74
|
+
...(options.llmProvider !== undefined ? { llmProvider: options.llmProvider } : {}),
|
|
75
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
76
|
+
...(options.ollamaBaseUrl !== undefined ? { ollamaBaseUrl: options.ollamaBaseUrl } : {}),
|
|
77
|
+
...(options.runDir !== undefined ? { runDir: options.runDir } : {}),
|
|
78
|
+
extraInputs: {
|
|
79
|
+
customTasks: normalizedTasks,
|
|
80
|
+
instructionText: normalizedTasks.join("\n"),
|
|
81
|
+
instructionFileName: null,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { toJSONSchema } from "zod";
|
|
3
|
+
import { config, resolveLlmRuntime } from "../config.js";
|
|
4
|
+
function getOpenAIClient() {
|
|
5
|
+
if (!config.openaiApiKey) {
|
|
6
|
+
throw new Error("OPENAI_API_KEY is required when the selected LLM provider is openai.");
|
|
7
|
+
}
|
|
8
|
+
return new OpenAI({
|
|
9
|
+
apiKey: config.openaiApiKey,
|
|
10
|
+
...(config.openaiBaseUrl ? { baseURL: config.openaiBaseUrl } : {})
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function cleanErrorMessage(error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
return message.replace(/\u001b\[[0-9;]*m/g, "").replace(/\s+/g, " ").trim() || "Unknown LLM error";
|
|
16
|
+
}
|
|
17
|
+
function stripJsonCodeFence(value) {
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
const fencedMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
20
|
+
return fencedMatch?.[1]?.trim() ?? trimmed;
|
|
21
|
+
}
|
|
22
|
+
async function generateWithOpenAI(options) {
|
|
23
|
+
const response = await getOpenAIClient().chat.completions.create({
|
|
24
|
+
model: options.model,
|
|
25
|
+
messages: [
|
|
26
|
+
{ role: "system", content: options.systemPrompt },
|
|
27
|
+
{
|
|
28
|
+
role: "user",
|
|
29
|
+
content: JSON.stringify(options.userPayload, null, 2)
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
response_format: {
|
|
33
|
+
type: "json_schema",
|
|
34
|
+
json_schema: {
|
|
35
|
+
name: options.schemaName,
|
|
36
|
+
schema: toJSONSchema(options.schema)
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
...(config.openaiTemperature !== undefined ? { temperature: config.openaiTemperature } : {}),
|
|
40
|
+
...(config.openaiMaxTokens !== undefined ? { max_tokens: config.openaiMaxTokens } : {})
|
|
41
|
+
}, {
|
|
42
|
+
...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
|
43
|
+
...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {})
|
|
44
|
+
});
|
|
45
|
+
const rawContent = response.choices[0]?.message?.content?.trim() ?? "";
|
|
46
|
+
if (!rawContent) {
|
|
47
|
+
throw new Error("OpenAI-compatible API returned an empty message content.");
|
|
48
|
+
}
|
|
49
|
+
return options.schema.parse(JSON.parse(stripJsonCodeFence(rawContent)));
|
|
50
|
+
}
|
|
51
|
+
const ollamaReachableCache = new Map();
|
|
52
|
+
async function ensureOllamaReachable(baseUrl) {
|
|
53
|
+
if (ollamaReachableCache.has(baseUrl)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(new URL("/api/tags", baseUrl), {
|
|
58
|
+
method: "GET",
|
|
59
|
+
signal: AbortSignal.timeout(5000)
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`Ollama server at ${baseUrl} returned ${response.status} ${response.statusText}. ` +
|
|
63
|
+
`Make sure Ollama is running — start it with: ollama serve`);
|
|
64
|
+
}
|
|
65
|
+
ollamaReachableCache.set(baseUrl, true);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
69
|
+
throw new Error(`Ollama server at ${baseUrl} did not respond within 5 seconds. ` +
|
|
70
|
+
`Make sure Ollama is running — start it with: ollama serve`);
|
|
71
|
+
}
|
|
72
|
+
if (error instanceof TypeError || (error instanceof Error && /fetch failed|ECONNREFUSED|ENOTFOUND/.test(error.message))) {
|
|
73
|
+
throw new Error(`Cannot connect to Ollama at ${baseUrl}. ` +
|
|
74
|
+
`Make sure Ollama is installed and running — start it with: ollama serve`);
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function requestOllama(options) {
|
|
80
|
+
await ensureOllamaReachable(options.baseUrl);
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeoutId = options.timeoutMs !== undefined
|
|
83
|
+
? setTimeout(() => controller.abort(new Error(`Ollama request timed out after ${options.timeoutMs}ms.`)), options.timeoutMs)
|
|
84
|
+
: null;
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(new URL("/api/chat", options.baseUrl), {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: {
|
|
89
|
+
"content-type": "application/json"
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
model: options.model,
|
|
93
|
+
messages: [
|
|
94
|
+
{ role: "system", content: options.systemPrompt },
|
|
95
|
+
{ role: "user", content: JSON.stringify(options.userPayload, null, 2) }
|
|
96
|
+
],
|
|
97
|
+
stream: false,
|
|
98
|
+
format: toJSONSchema(options.schema),
|
|
99
|
+
options: {
|
|
100
|
+
temperature: 0
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
signal: controller.signal
|
|
104
|
+
});
|
|
105
|
+
const responseText = await response.text();
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Ollama request failed with ${response.status} ${response.statusText}: ${responseText.slice(0, 400)}`);
|
|
108
|
+
}
|
|
109
|
+
const parsedResponse = JSON.parse(responseText);
|
|
110
|
+
if (parsedResponse.error) {
|
|
111
|
+
throw new Error(`Ollama returned an error: ${parsedResponse.error}`);
|
|
112
|
+
}
|
|
113
|
+
const rawContent = parsedResponse.message?.content?.trim() ?? "";
|
|
114
|
+
if (!rawContent) {
|
|
115
|
+
throw new Error("Ollama returned an empty message content.");
|
|
116
|
+
}
|
|
117
|
+
return options.schema.parse(JSON.parse(stripJsonCodeFence(rawContent)));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
121
|
+
throw new Error(options.timeoutMs !== undefined ? `Ollama request timed out after ${options.timeoutMs}ms.` : "Ollama request timed out.");
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
if (timeoutId !== null) {
|
|
127
|
+
clearTimeout(timeoutId);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function generateWithOllama(options) {
|
|
132
|
+
const maxAttempts = 1 + Math.max(0, options.maxRetries ?? 0);
|
|
133
|
+
let lastError;
|
|
134
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
135
|
+
try {
|
|
136
|
+
return await requestOllama(options);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
lastError = error;
|
|
140
|
+
if (attempt >= maxAttempts) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error(cleanErrorMessage(lastError));
|
|
146
|
+
}
|
|
147
|
+
/** Minimum delay (ms) between consecutive LLM requests to avoid rate-limiting. */
|
|
148
|
+
const LLM_REQUEST_DELAY_MS = Math.max(0, Number(process.env.LLM_REQUEST_DELAY_MS) || 4000);
|
|
149
|
+
let lastLlmRequestTimestamp = 0;
|
|
150
|
+
async function throttleLlmRequest() {
|
|
151
|
+
if (LLM_REQUEST_DELAY_MS <= 0) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const elapsed = now - lastLlmRequestTimestamp;
|
|
156
|
+
if (elapsed < LLM_REQUEST_DELAY_MS) {
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, LLM_REQUEST_DELAY_MS - elapsed));
|
|
158
|
+
}
|
|
159
|
+
lastLlmRequestTimestamp = Date.now();
|
|
160
|
+
}
|
|
161
|
+
export async function generateStructured(options) {
|
|
162
|
+
await throttleLlmRequest();
|
|
163
|
+
const runtime = resolveLlmRuntime({
|
|
164
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
165
|
+
...(options.model ? { model: options.model } : {}),
|
|
166
|
+
...(options.ollamaBaseUrl ? { ollamaBaseUrl: options.ollamaBaseUrl } : {})
|
|
167
|
+
});
|
|
168
|
+
if (runtime.provider === "ollama") {
|
|
169
|
+
return generateWithOllama({
|
|
170
|
+
baseUrl: runtime.ollamaBaseUrl,
|
|
171
|
+
model: runtime.model,
|
|
172
|
+
systemPrompt: options.systemPrompt,
|
|
173
|
+
userPayload: options.userPayload,
|
|
174
|
+
schema: options.schema,
|
|
175
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
176
|
+
...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {})
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return generateWithOpenAI({
|
|
180
|
+
model: runtime.model,
|
|
181
|
+
systemPrompt: options.systemPrompt,
|
|
182
|
+
userPayload: options.userPayload,
|
|
183
|
+
schemaName: options.schemaName,
|
|
184
|
+
schema: options.schema,
|
|
185
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
186
|
+
...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {})
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getPaystackClient } from './client.js';
|
|
4
|
+
import { PaystackCustomerSchema, PaystackDVASchema, } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Manages the agent's Dedicated Virtual Account (DVA).
|
|
7
|
+
*
|
|
8
|
+
* On first call, createAgentAccount() will:
|
|
9
|
+
* 1. Create (or retrieve) a Paystack customer for the agent
|
|
10
|
+
* 2. Create a dedicated virtual bank account tied to that customer
|
|
11
|
+
* 3. Cache the result locally so restarts don't re-create it
|
|
12
|
+
*
|
|
13
|
+
* The cached account file is written to:
|
|
14
|
+
* <SITE_AGENT_DATA_DIR>/paystack/dva.json (if env var set)
|
|
15
|
+
* ./data/paystack/dva.json (fallback)
|
|
16
|
+
*/
|
|
17
|
+
const DVA_PROVIDER = process.env['PAYSTACK_DVA_PROVIDER'] ??
|
|
18
|
+
'wema-bank';
|
|
19
|
+
const AGENT_EMAIL = process.env['PAYSTACK_AGENT_EMAIL'] || 'agent@site-agent-pro.com';
|
|
20
|
+
const AGENT_FIRST_NAME = process.env['PAYSTACK_AGENT_FIRST_NAME'] ?? 'Site';
|
|
21
|
+
const AGENT_LAST_NAME = process.env['PAYSTACK_AGENT_LAST_NAME'] ?? 'Agent';
|
|
22
|
+
const AGENT_PHONE = process.env['PAYSTACK_AGENT_PHONE'] ?? '';
|
|
23
|
+
function dvaCachePath() {
|
|
24
|
+
const base = process.env['SITE_AGENT_DATA_DIR'] ?? path.join(process.cwd(), 'data');
|
|
25
|
+
return path.join(base, 'paystack', 'dva.json');
|
|
26
|
+
}
|
|
27
|
+
function loadCachedDVA() {
|
|
28
|
+
try {
|
|
29
|
+
const raw = fs.readFileSync(dvaCachePath(), 'utf8');
|
|
30
|
+
const parsed = PaystackDVASchema.safeParse(JSON.parse(raw));
|
|
31
|
+
return parsed.success ? parsed.data : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function cacheDVA(dva) {
|
|
38
|
+
const p = dvaCachePath();
|
|
39
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
40
|
+
fs.writeFileSync(p, JSON.stringify(dva, null, 2), 'utf8');
|
|
41
|
+
}
|
|
42
|
+
// ─── Customer helpers ─────────────────────────────────────────────────────────
|
|
43
|
+
async function findOrCreateCustomer() {
|
|
44
|
+
const client = getPaystackClient();
|
|
45
|
+
// Search for existing customer by email
|
|
46
|
+
try {
|
|
47
|
+
const existing = await client.get(`/customer/${encodeURIComponent(AGENT_EMAIL)}`);
|
|
48
|
+
const parsed = PaystackCustomerSchema.safeParse(existing);
|
|
49
|
+
if (parsed.success) {
|
|
50
|
+
console.log(`[paystack/account] Found existing customer: ${parsed.data.customer_code}`);
|
|
51
|
+
// If live and phone is missing, update it
|
|
52
|
+
if (AGENT_PHONE && !parsed.data.phone) {
|
|
53
|
+
console.log(`[paystack/account] Updating customer ${parsed.data.customer_code} with missing phone number…`);
|
|
54
|
+
await client.put(`/customer/${parsed.data.customer_code}`, { phone: AGENT_PHONE });
|
|
55
|
+
}
|
|
56
|
+
return parsed.data;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// 404 → customer doesn't exist yet, fall through to create
|
|
61
|
+
}
|
|
62
|
+
const created = await client.post('/customer', {
|
|
63
|
+
email: AGENT_EMAIL,
|
|
64
|
+
first_name: AGENT_FIRST_NAME,
|
|
65
|
+
last_name: AGENT_LAST_NAME,
|
|
66
|
+
phone: AGENT_PHONE,
|
|
67
|
+
});
|
|
68
|
+
const parsed = PaystackCustomerSchema.parse(created);
|
|
69
|
+
console.log(`[paystack/account] Created new customer: ${parsed.customer_code}`);
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
// ─── DVA helpers ──────────────────────────────────────────────────────────────
|
|
73
|
+
async function createDVA(customerCode) {
|
|
74
|
+
const client = getPaystackClient();
|
|
75
|
+
const raw = await client.post('/dedicated_account', {
|
|
76
|
+
customer: customerCode,
|
|
77
|
+
preferred_bank: DVA_PROVIDER,
|
|
78
|
+
});
|
|
79
|
+
return PaystackDVASchema.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
async function listDVAsForCustomer(customerCode) {
|
|
82
|
+
const client = getPaystackClient();
|
|
83
|
+
const raw = await client.get('/dedicated_account', {
|
|
84
|
+
customer: customerCode,
|
|
85
|
+
});
|
|
86
|
+
return Array.isArray(raw) ? raw.map((d) => PaystackDVASchema.parse(d)) : [];
|
|
87
|
+
}
|
|
88
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Returns the agent's dedicated virtual account, creating it on first call.
|
|
91
|
+
* Subsequent calls return the locally cached result (no extra API hit).
|
|
92
|
+
*/
|
|
93
|
+
export async function getAgentAccount() {
|
|
94
|
+
const cached = loadCachedDVA();
|
|
95
|
+
if (cached) {
|
|
96
|
+
return cached;
|
|
97
|
+
}
|
|
98
|
+
const customer = await findOrCreateCustomer();
|
|
99
|
+
// Check if a DVA already exists for this customer
|
|
100
|
+
const existing = await listDVAsForCustomer(customer.customer_code);
|
|
101
|
+
if (existing.length > 0) {
|
|
102
|
+
const dva = existing[0];
|
|
103
|
+
cacheDVA(dva);
|
|
104
|
+
return dva;
|
|
105
|
+
}
|
|
106
|
+
// Create a new DVA
|
|
107
|
+
const dva = await createDVA(customer.customer_code);
|
|
108
|
+
cacheDVA(dva);
|
|
109
|
+
console.log(`[paystack/account] DVA created: ${dva.account_number} (${dva.bank.name})`);
|
|
110
|
+
return dva;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Pretty-prints the agent's virtual account details.
|
|
114
|
+
*/
|
|
115
|
+
export function formatAccountDetails(dva) {
|
|
116
|
+
return [
|
|
117
|
+
`Bank: ${dva.bank.name}`,
|
|
118
|
+
`Account Number: ${dva.account_number}`,
|
|
119
|
+
`Account Name: ${dva.account_name}`,
|
|
120
|
+
`Currency: ${dva.currency}`,
|
|
121
|
+
`Active: ${dva.active ? 'Yes' : 'No'}`,
|
|
122
|
+
].join('\n');
|
|
123
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fetch-based wrapper for the Paystack REST API.
|
|
3
|
+
* Uses Node 20+ built-in fetch — no external dependencies required.
|
|
4
|
+
* Reads PAYSTACK_SECRET_KEY from the environment.
|
|
5
|
+
*
|
|
6
|
+
* All public methods return the unwrapped `data` field from
|
|
7
|
+
* Paystack's standard { status, message, data } envelope, and
|
|
8
|
+
* throw a descriptive PaystackError on any non-2xx response.
|
|
9
|
+
*/
|
|
10
|
+
const BASE_URL = 'https://api.paystack.co';
|
|
11
|
+
const TIMEOUT_MS = 30_000;
|
|
12
|
+
export class PaystackError extends Error {
|
|
13
|
+
statusCode;
|
|
14
|
+
raw;
|
|
15
|
+
constructor(statusCode, message, raw) {
|
|
16
|
+
super(`Paystack API error (${statusCode}): ${message}`);
|
|
17
|
+
this.statusCode = statusCode;
|
|
18
|
+
this.raw = raw;
|
|
19
|
+
this.name = 'PaystackError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class PaystackClient {
|
|
23
|
+
headers;
|
|
24
|
+
constructor(secretKey) {
|
|
25
|
+
const key = secretKey ?? process.env['PAYSTACK_SECRET_KEY'];
|
|
26
|
+
if (!key) {
|
|
27
|
+
throw new Error('PAYSTACK_SECRET_KEY is not set. Add it to your .env file.');
|
|
28
|
+
}
|
|
29
|
+
this.headers = {
|
|
30
|
+
Authorization: `Bearer ${key}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
Accept: 'application/json',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// ─── Core request helpers ─────────────────────────────────────────────────
|
|
36
|
+
async get(path, params) {
|
|
37
|
+
const url = new URL(`${BASE_URL}${path}`);
|
|
38
|
+
if (params) {
|
|
39
|
+
for (const [k, v] of Object.entries(params)) {
|
|
40
|
+
if (v !== undefined && v !== null) {
|
|
41
|
+
url.searchParams.set(k, String(v));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return this.request(url.toString(), { method: 'GET' });
|
|
46
|
+
}
|
|
47
|
+
async post(path, body) {
|
|
48
|
+
return this.request(`${BASE_URL}${path}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async put(path, body) {
|
|
54
|
+
return this.request(`${BASE_URL}${path}`, {
|
|
55
|
+
method: 'PUT',
|
|
56
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// ─── Internal fetch with timeout ─────────────────────────────────────────
|
|
60
|
+
async request(url, init) {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await fetch(url, {
|
|
66
|
+
...init,
|
|
67
|
+
headers: this.headers,
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
74
|
+
throw new PaystackError(0, `Request timed out after ${TIMEOUT_MS}ms`);
|
|
75
|
+
}
|
|
76
|
+
throw new PaystackError(0, String(err), err);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
let json;
|
|
82
|
+
try {
|
|
83
|
+
json = (await res.json());
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new PaystackError(res.status, `Non-JSON response from Paystack (${res.status})`);
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok || !json.status) {
|
|
89
|
+
throw new PaystackError(res.status, json.message ?? `HTTP ${res.status}`, json);
|
|
90
|
+
}
|
|
91
|
+
return json.data;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Singleton — reuse across the process lifetime
|
|
95
|
+
let _client = null;
|
|
96
|
+
export function getPaystackClient() {
|
|
97
|
+
if (!_client)
|
|
98
|
+
_client = new PaystackClient();
|
|
99
|
+
return _client;
|
|
100
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/paystack/index.ts
|
|
3
|
+
* ─────────────────────
|
|
4
|
+
* Public surface of the Paystack module.
|
|
5
|
+
* Import from here rather than individual files.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { getAgentAccount, sendMoney, paystackWebhookMiddleware } from './paystack/index.js';
|
|
9
|
+
*/
|
|
10
|
+
export { PaystackClient, PaystackError, getPaystackClient } from './client.js';
|
|
11
|
+
export { getAgentAccount, formatAccountDetails } from './account.js';
|
|
12
|
+
export { ensureRecipient, sendTransfer, sendMoney, listBanks, resolveBankCode, listTransactions, } from './transfer.js';
|
|
13
|
+
export { handleWebhook, paystackWebhookMiddleware, } from './webhook.js';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paystack integration smoke-test.
|
|
3
|
+
*
|
|
4
|
+
* Run with:
|
|
5
|
+
* npx tsx src/paystack/test-paystack.ts
|
|
6
|
+
*
|
|
7
|
+
* What it does (in order):
|
|
8
|
+
* 1. Verifies PAYSTACK_SECRET_KEY is set
|
|
9
|
+
* 2. Creates / retrieves the agent's Dedicated Virtual Account (DVA)
|
|
10
|
+
* 3. Prints the bank account details
|
|
11
|
+
* 4. Fetches the list of supported banks and resolves a sample bank name
|
|
12
|
+
* 5. Runs a DRY-RUN transfer (no money moves unless PAYSTACK_TRANSFER_ENABLED=true)
|
|
13
|
+
*
|
|
14
|
+
* No flags needed — all config is read from .env.
|
|
15
|
+
*/
|
|
16
|
+
import 'dotenv/config';
|
|
17
|
+
import { getAgentAccount, formatAccountDetails, sendMoney, listBanks, resolveBankCode, } from './index.js';
|
|
18
|
+
const DIVIDER = '─'.repeat(56);
|
|
19
|
+
function section(title) {
|
|
20
|
+
console.log(`\n${DIVIDER}`);
|
|
21
|
+
console.log(` ${title}`);
|
|
22
|
+
console.log(DIVIDER);
|
|
23
|
+
}
|
|
24
|
+
async function main() {
|
|
25
|
+
console.log('\n🚀 Site Agent Pro — Paystack Integration Test\n');
|
|
26
|
+
// ── 1. Env check ────────────────────────────────────────────────────────────
|
|
27
|
+
section('1 / 5 Environment');
|
|
28
|
+
const key = process.env['PAYSTACK_SECRET_KEY'];
|
|
29
|
+
if (!key) {
|
|
30
|
+
console.error('❌ PAYSTACK_SECRET_KEY is not set in .env — aborting.');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const mode = key.startsWith('sk_live') ? 'LIVE 🔴' : 'TEST 🟡';
|
|
34
|
+
console.log(` Key mode : ${mode}`);
|
|
35
|
+
console.log(` Transfers: ${process.env['PAYSTACK_TRANSFER_ENABLED'] === 'true'
|
|
36
|
+
? 'ENABLED (real money will move)'
|
|
37
|
+
: 'DRY-RUN (safe — nothing will be sent)'}`);
|
|
38
|
+
// ── 2. Dedicated Virtual Account ────────────────────────────────────────────
|
|
39
|
+
section('2 / 5 Dedicated Virtual Account');
|
|
40
|
+
console.log(' Fetching / creating agent DVA…');
|
|
41
|
+
const dva = await getAgentAccount();
|
|
42
|
+
console.log('\n' + formatAccountDetails(dva).replace(/^/gm, ' '));
|
|
43
|
+
// ── 3. Bank list ─────────────────────────────────────────────────────────────
|
|
44
|
+
section('3 / 5 Bank List');
|
|
45
|
+
console.log(' Fetching supported banks…');
|
|
46
|
+
const banks = await listBanks();
|
|
47
|
+
console.log(` ${banks.length} banks returned from Paystack.\n`);
|
|
48
|
+
// Show first 5 as a sample
|
|
49
|
+
banks.slice(0, 5).forEach((b) => {
|
|
50
|
+
console.log(` ${b.code.padEnd(6)} ${b.name}`);
|
|
51
|
+
});
|
|
52
|
+
if (banks.length > 5) {
|
|
53
|
+
console.log(` … and ${banks.length - 5} more`);
|
|
54
|
+
}
|
|
55
|
+
// ── 4. Bank code resolution ──────────────────────────────────────────────────
|
|
56
|
+
section('4 / 5 Bank Code Resolution');
|
|
57
|
+
const samples = ['guaranty', 'zenith', 'access', 'firstbank'];
|
|
58
|
+
for (const name of samples) {
|
|
59
|
+
const code = await resolveBankCode(name);
|
|
60
|
+
console.log(` "${name}" → ${code ?? '(not found)'}`);
|
|
61
|
+
}
|
|
62
|
+
// ── 5. Dry-run transfer ──────────────────────────────────────────────────────
|
|
63
|
+
section('5 / 5 Transfer (dry-run safe)');
|
|
64
|
+
console.log(' Initiating test transfer…\n');
|
|
65
|
+
const { recipient, transfer } = await sendMoney({
|
|
66
|
+
accountNumber: '0123456789', // ← replace with a real account for live tests
|
|
67
|
+
bankCode: '058', // GTBank
|
|
68
|
+
recipientName: 'Test Recipient',
|
|
69
|
+
amountNaira: 100,
|
|
70
|
+
reason: 'Site Agent Pro smoke test',
|
|
71
|
+
});
|
|
72
|
+
console.log(` Recipient code : ${recipient.recipient_code}`);
|
|
73
|
+
console.log(` Transfer code : ${transfer.transfer_code}`);
|
|
74
|
+
console.log(` Amount : ₦${(transfer.amount / 100).toLocaleString()}`);
|
|
75
|
+
console.log(` Status : ${transfer.status}`);
|
|
76
|
+
console.log(` Reference : ${transfer.reference}`);
|
|
77
|
+
console.log(`\n${DIVIDER}`);
|
|
78
|
+
console.log(' ✅ All checks passed.\n');
|
|
79
|
+
}
|
|
80
|
+
main().catch((err) => {
|
|
81
|
+
console.error('\n❌ Test failed:', err);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|