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.
Files changed (81) hide show
  1. package/README.md +689 -0
  2. package/dist/auth/credentialStore.js +62 -0
  3. package/dist/auth/inbox.js +193 -0
  4. package/dist/auth/profile.js +379 -0
  5. package/dist/auth/runner.js +1124 -0
  6. package/dist/backend/dashboardData.js +194 -0
  7. package/dist/backend/runArtifacts.js +48 -0
  8. package/dist/backend/runRepository.js +93 -0
  9. package/dist/bin.js +2 -0
  10. package/dist/cli/backfillSiteChecks.js +143 -0
  11. package/dist/cli/run.js +309 -0
  12. package/dist/cli/trade.js +69 -0
  13. package/dist/config.js +199 -0
  14. package/dist/core/agentProfiles.js +55 -0
  15. package/dist/core/aggregateReport.js +382 -0
  16. package/dist/core/audit.js +30 -0
  17. package/dist/core/customTaskSuite.js +148 -0
  18. package/dist/core/evaluator.js +217 -0
  19. package/dist/core/executor.js +788 -0
  20. package/dist/core/fallbackReport.js +335 -0
  21. package/dist/core/formHeuristics.js +411 -0
  22. package/dist/core/gameplaySummary.js +164 -0
  23. package/dist/core/interaction.js +202 -0
  24. package/dist/core/pageState.js +201 -0
  25. package/dist/core/planner.js +1669 -0
  26. package/dist/core/processSubmissionBatch.js +204 -0
  27. package/dist/core/runAuditJob.js +170 -0
  28. package/dist/core/runner.js +2352 -0
  29. package/dist/core/siteBrief.js +107 -0
  30. package/dist/core/siteChecks.js +1526 -0
  31. package/dist/core/taskDirectives.js +279 -0
  32. package/dist/core/taskHeuristics.js +263 -0
  33. package/dist/dashboard/client.js +1256 -0
  34. package/dist/dashboard/contracts.js +95 -0
  35. package/dist/dashboard/narrative.js +277 -0
  36. package/dist/dashboard/server.js +458 -0
  37. package/dist/dashboard/theme.js +888 -0
  38. package/dist/index.js +84 -0
  39. package/dist/llm/client.js +188 -0
  40. package/dist/paystack/account.js +123 -0
  41. package/dist/paystack/client.js +100 -0
  42. package/dist/paystack/index.js +13 -0
  43. package/dist/paystack/test-paystack.js +83 -0
  44. package/dist/paystack/transfer.js +138 -0
  45. package/dist/paystack/types.js +74 -0
  46. package/dist/paystack/webhook.js +121 -0
  47. package/dist/prompts/browserAgent.js +124 -0
  48. package/dist/prompts/reviewer.js +71 -0
  49. package/dist/reporting/clickReplay.js +290 -0
  50. package/dist/reporting/html.js +930 -0
  51. package/dist/reporting/markdown.js +238 -0
  52. package/dist/reporting/template.js +1141 -0
  53. package/dist/schemas/types.js +361 -0
  54. package/dist/submissions/customTasks.js +196 -0
  55. package/dist/submissions/html.js +770 -0
  56. package/dist/submissions/model.js +56 -0
  57. package/dist/submissions/publicUrl.js +76 -0
  58. package/dist/submissions/service.js +74 -0
  59. package/dist/submissions/store.js +37 -0
  60. package/dist/submissions/types.js +65 -0
  61. package/dist/trade/engine.js +241 -0
  62. package/dist/trade/evm/erc20.js +44 -0
  63. package/dist/trade/extractor.js +148 -0
  64. package/dist/trade/policy.js +35 -0
  65. package/dist/trade/session.js +31 -0
  66. package/dist/trade/types.js +107 -0
  67. package/dist/trade/validator.js +148 -0
  68. package/dist/utils/files.js +59 -0
  69. package/dist/utils/log.js +24 -0
  70. package/dist/utils/playwrightCompat.js +14 -0
  71. package/dist/utils/time.js +3 -0
  72. package/dist/wallet/provider.js +345 -0
  73. package/dist/wallet/relay.js +129 -0
  74. package/dist/wallet/wallet.js +178 -0
  75. package/docs/01-installation.md +134 -0
  76. package/docs/02-running-your-first-audit.md +136 -0
  77. package/docs/03-configuration.md +233 -0
  78. package/docs/04-how-the-agent-thinks.md +41 -0
  79. package/docs/05-extending-personas-and-tasks.md +42 -0
  80. package/docs/06-hardening-for-production.md +92 -0
  81. 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
+ });