@relvo/mcp 0.1.2 → 0.1.4
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 +47 -154
- package/bin/relvo-mcp.mjs +1 -1
- package/dist/client/agent-run.js +681 -0
- package/dist/client/browser.js +652 -0
- package/dist/client/evm-wallet.js +214 -0
- package/dist/client/index.js +3 -360
- package/dist/client/local-input.js +18 -11
- package/dist/client/local-profile.js +22 -110
- package/dist/client/local-setup.js +0 -1
- package/dist/client/tool-name-hints.js +48 -0
- package/dist/client/wallet-access.js +207 -0
- package/dist/mcp/agent-server.js +237 -0
- package/dist/mcp/context.js +3 -2
- package/dist/mcp/host-schema.js +47 -0
- package/dist/mcp/instructions.js +12 -2
- package/dist/mcp/local-upload.js +12 -4
- package/dist/mcp/operations.js +30 -0
- package/dist/mcp/runtime.js +25 -5
- package/dist/mcp/server.js +191 -37
- package/dist/mcp/version.js +1 -0
- package/dist/schema/accountRequestMethods.js +1 -0
- package/dist/schema/cronValidator.js +150 -0
- package/dist/schema/platformApiKey.js +5 -5
- package/dist/schema/platformBillingConstants.js +3 -0
- package/dist/schema/platformCapabilityFamilies.js +269 -0
- package/dist/schema/platformConnectionCatalog.js +20 -3
- package/dist/schema/platformConnectionPolicy.js +26 -0
- package/dist/schema/platformCustomTools.js +113 -0
- package/dist/schema/platformFunding.js +9 -0
- package/dist/schema/platformHttpConstraints.js +7 -0
- package/dist/schema/platformMcpTools.js +7 -0
- package/dist/schema/platformMetaTools.js +8 -6
- package/dist/schema/platformMonitorSchedule.js +123 -0
- package/dist/schema/platformMonitors.js +159 -0
- package/dist/schema/platformOAuth.js +7 -0
- package/dist/schema/platformPublicExecution.js +53 -15
- package/dist/schema/platformRoutes.js +61 -1
- package/dist/schema/platformSkills.js +1 -1
- package/dist/schema/platformTeamSecretName.js +3 -0
- package/dist/schema/platformTeamSkills.js +49 -0
- package/dist/schema/platformToolAccess.js +18 -0
- package/dist/schema/platformTriggers.js +152 -0
- package/dist/schema/platformWorkflows.js +100 -0
- package/dist/schema/scheduleTimeZone.js +15 -0
- package/dist/schema/toolDiscovery.js +20 -3
- package/dist/schema/toolInputRepair.js +110 -0
- package/dist/schema/toolResultContext.js +1 -1
- package/package.json +1 -1
- package/dist/schema/amazonAds.js +0 -13
- package/dist/schema/oauth2.js +0 -64
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { platformLocalSetupClientSchema, platformLocalSetupStartSchema, platformLocalSetupProofSchema, platformLocalSetupPollSchema } from "./local-setup.js";
|
|
8
8
|
import { configFromEnvironment, hasRelvoEnvironment } from "./index.js";
|
|
9
|
-
const registeredSchema = z.object({ client: z.enum(["claude", "codex"]), name: z.string().regex(/^
|
|
10
|
-
const common = { managedBy: z.
|
|
11
|
-
legacyProfileSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
9
|
+
const registeredSchema = z.object({ client: z.enum(["claude", "codex"]), name: z.string().regex(/^relvo-[a-f0-9]{24}$/), command: z.string().min(1), args: z.array(z.string()).min(1) }).strict();
|
|
10
|
+
const common = { managedBy: z.literal("relvo-cli"), version: z.literal(1), apiUrl: z.string().url(), consoleUrl: z.string().url(), local: z.boolean(),
|
|
12
11
|
installationId: z.string().regex(/^[a-f0-9]{24}$/), registrations: z.array(registeredSchema).max(2).default([]) };
|
|
13
12
|
export const localProfileSchema = z.discriminatedUnion("state", [
|
|
14
13
|
z.object({ ...common, state: z.literal("pending"), client: platformLocalSetupClientSchema, request: platformLocalSetupStartSchema }).strict(),
|
|
15
14
|
z.object({ ...common, state: z.literal("ready"), grant: platformLocalSetupPollSchema.options[1], pendingAck: platformLocalSetupProofSchema.optional() }).strict(),
|
|
16
15
|
]);
|
|
16
|
+
const installationSchema = z.object({ managedBy: z.literal("relvo-cli"), version: z.literal(1),
|
|
17
|
+
installationId: common.installationId, registrations: common.registrations }).strict();
|
|
17
18
|
export const localProfilePath = (home = homedir()) => join(home, ".config", "relvo", "profile.json");
|
|
18
|
-
export const legacyLocalProfilePath = (home = homedir()) => join(home, ".config", "toolhub", "profile.json");
|
|
19
19
|
function owned(mode, uid, privateMode) {
|
|
20
20
|
if (typeof process.getuid === "function" && uid !== process.getuid() || mode & (privateMode ? 0o077 : 0o022))
|
|
21
21
|
throw new Error("Relvo local storage has unsafe ownership or permissions.");
|
|
@@ -39,7 +39,7 @@ export async function safeDirectory(path, create = false, privateMode = false) {
|
|
|
39
39
|
export async function profileDirectory(path = localProfilePath(), create = false) {
|
|
40
40
|
if (process.platform === "win32")
|
|
41
41
|
throw new Error("Relvo private local profiles currently require POSIX file permissions.");
|
|
42
|
-
if (!isAbsolute(path) || resolve(path) !== path || !/\/\.config\/
|
|
42
|
+
if (!isAbsolute(path) || resolve(path) !== path || !/\/\.config\/relvo\/profile\.json$/.test(path))
|
|
43
43
|
throw new Error("Use the canonical managed Relvo profile path.");
|
|
44
44
|
const root = dirname(dirname(dirname(path)));
|
|
45
45
|
if (await realpath(root) !== root)
|
|
@@ -103,68 +103,6 @@ export async function writePrivateText(path, value, privateParent = true, maximu
|
|
|
103
103
|
}
|
|
104
104
|
export async function writePrivateJson(path, value, privateParent = true, maximumBytes = 65536) { await writePrivateText(path, JSON.stringify(value) + "\n", privateParent, maximumBytes); }
|
|
105
105
|
export async function readLocalProfile(path = localProfilePath()) {
|
|
106
|
-
try {
|
|
107
|
-
path = await migratedProfileTarget(path);
|
|
108
|
-
let profile = await readOwnedProfile(path);
|
|
109
|
-
if (!profile && path.endsWith("/.config/relvo/profile.json")) {
|
|
110
|
-
const legacy = path.replace(/\/relvo\/profile\.json$/, "/toolhub/profile.json");
|
|
111
|
-
const previous = await readOwnedProfile(legacy);
|
|
112
|
-
if (previous) {
|
|
113
|
-
await assertLegacySetupIdle(legacy);
|
|
114
|
-
const raw = await readPrivateText(legacy);
|
|
115
|
-
const migrated = localProfileSchema.parse({ ...localProfileSchema.parse(JSON.parse(raw)), managedBy: "relvo-cli", legacyProfileSha256: digest(raw) });
|
|
116
|
-
await profileDirectory(path, true);
|
|
117
|
-
await createProfileExclusive(path, migrated);
|
|
118
|
-
profile = await readOwnedProfile(path);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (!profile && path.endsWith("/.config/toolhub/profile.json"))
|
|
122
|
-
return readLocalProfile(path.replace(/\/toolhub\/profile\.json$/, "/relvo/profile.json"));
|
|
123
|
-
if (profile?.legacyProfileSha256)
|
|
124
|
-
await unchangedLegacyProfile(path, profile.legacyProfileSha256);
|
|
125
|
-
return profile;
|
|
126
|
-
}
|
|
127
|
-
catch (error) {
|
|
128
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
129
|
-
return null;
|
|
130
|
-
throw new Error("Relvo local profile is unsafe or invalid. No credentials were changed.");
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const digest = (raw) => createHash("sha256").update(raw).digest("hex");
|
|
134
|
-
async function createProfileExclusive(path, profile) {
|
|
135
|
-
const value = JSON.stringify(profile) + "\n";
|
|
136
|
-
if (Buffer.byteLength(value, "utf8") > 65536)
|
|
137
|
-
throw new Error("The migrated profile exceeds its private file limit.");
|
|
138
|
-
const temporary = join(dirname(path), `.relvo-migrate-${randomUUID()}.tmp`);
|
|
139
|
-
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
140
|
-
try {
|
|
141
|
-
try {
|
|
142
|
-
await handle.writeFile(value);
|
|
143
|
-
await handle.sync();
|
|
144
|
-
}
|
|
145
|
-
finally {
|
|
146
|
-
await handle.close();
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
await link(temporary, path);
|
|
150
|
-
}
|
|
151
|
-
catch (error) {
|
|
152
|
-
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
153
|
-
throw error;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
finally {
|
|
157
|
-
await unlink(temporary);
|
|
158
|
-
}
|
|
159
|
-
const parent = await open(dirname(path), constants.O_RDONLY);
|
|
160
|
-
try {
|
|
161
|
-
await parent.sync();
|
|
162
|
-
}
|
|
163
|
-
finally {
|
|
164
|
-
await parent.close();
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
async function readOwnedProfile(path) {
|
|
168
106
|
try {
|
|
169
107
|
await profileDirectory(path);
|
|
170
108
|
return localProfileSchema.parse(await readPrivateJson(path));
|
|
@@ -172,59 +110,33 @@ async function readOwnedProfile(path) {
|
|
|
172
110
|
catch (error) {
|
|
173
111
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
174
112
|
return null;
|
|
175
|
-
throw
|
|
113
|
+
throw new Error("Relvo local profile is unsafe or invalid. No credentials were changed.");
|
|
176
114
|
}
|
|
177
115
|
}
|
|
178
|
-
async function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const profile = await readOwnedProfile(canonical);
|
|
183
|
-
return profile && (profile.legacyProfileSha256 || !await readOwnedProfile(path)) ? canonical : path;
|
|
116
|
+
export async function writeLocalProfile(path, profile) {
|
|
117
|
+
await profileDirectory(path, true);
|
|
118
|
+
await readLocalProfile(path);
|
|
119
|
+
await writePrivateJson(path, localProfileSchema.parse({ ...profile, managedBy: "relvo-cli" }));
|
|
184
120
|
}
|
|
185
|
-
async function
|
|
186
|
-
let lock;
|
|
121
|
+
export async function readLocalInstallation(path = localProfilePath()) {
|
|
187
122
|
try {
|
|
188
|
-
|
|
123
|
+
const directory = await profileDirectory(path);
|
|
124
|
+
return installationSchema.parse(await readPrivateJson(join(directory, "installation.json")));
|
|
189
125
|
}
|
|
190
126
|
catch (error) {
|
|
191
127
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
192
|
-
return;
|
|
193
|
-
throw
|
|
194
|
-
}
|
|
195
|
-
try {
|
|
196
|
-
process.kill(lock.pid, 0);
|
|
197
|
-
}
|
|
198
|
-
catch (error) {
|
|
199
|
-
if (error instanceof Error && "code" in error && error.code === "ESRCH")
|
|
200
|
-
return;
|
|
201
|
-
throw error;
|
|
128
|
+
return null;
|
|
129
|
+
throw new Error("Relvo installation ownership is unsafe or invalid. No registrations were changed.");
|
|
202
130
|
}
|
|
203
|
-
throw new Error("The existing setup is active. Wait for it to finish before migrating.");
|
|
204
|
-
}
|
|
205
|
-
async function unchangedLegacyProfile(path, expected) {
|
|
206
|
-
if (!path.endsWith("/.config/relvo/profile.json"))
|
|
207
|
-
throw new Error("Invalid migrated profile path");
|
|
208
|
-
const legacy = path.replace(/\/relvo\/profile\.json$/, "/toolhub/profile.json");
|
|
209
|
-
if (!await readOwnedProfile(legacy))
|
|
210
|
-
return null;
|
|
211
|
-
if (digest(await readPrivateText(legacy)) !== expected)
|
|
212
|
-
throw new Error("The existing profile changed after migration and was preserved.");
|
|
213
|
-
return legacy;
|
|
214
|
-
}
|
|
215
|
-
export async function writeLocalProfile(path, profile) {
|
|
216
|
-
path = await migratedProfileTarget(path);
|
|
217
|
-
await profileDirectory(path, true);
|
|
218
|
-
const previous = await readLocalProfile(path);
|
|
219
|
-
await writePrivateJson(path, localProfileSchema.parse({ ...profile, managedBy: "relvo-cli", ...(previous?.legacyProfileSha256 ? { legacyProfileSha256: previous.legacyProfileSha256 } : {}) }));
|
|
220
131
|
}
|
|
221
132
|
export async function removeLocalProfile(path) {
|
|
222
|
-
path = await migratedProfileTarget(path);
|
|
223
133
|
const profile = await readLocalProfile(path);
|
|
224
134
|
if (profile) {
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
|
|
135
|
+
const installation = await readLocalInstallation(path);
|
|
136
|
+
if (installation && installation.installationId !== profile.installationId)
|
|
137
|
+
throw new Error("Relvo installation ownership changed. The login was preserved.");
|
|
138
|
+
await writePrivateJson(join(dirname(path), "installation.json"), installationSchema.parse({ managedBy: "relvo-cli", version: 1,
|
|
139
|
+
installationId: profile.installationId, registrations: profile.registrations }));
|
|
228
140
|
await unlink(path);
|
|
229
141
|
}
|
|
230
142
|
}
|
|
@@ -8,7 +8,6 @@ export function relvoApiUrl(value, local = false) {
|
|
|
8
8
|
throw new Error("Relvo URLs must use HTTPS; --local permits loopback HTTP only.");
|
|
9
9
|
return url.href.replace(/\/$/, "");
|
|
10
10
|
}
|
|
11
|
-
export const toolhubApiUrl = relvoApiUrl;
|
|
12
11
|
export function verificationUrl(value, consoleUrl, authorizationRequest, local = false) {
|
|
13
12
|
const trusted = new URL(relvoApiUrl(consoleUrl, local));
|
|
14
13
|
if (trusted.pathname !== "/")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const MAX_REMEMBERED_NAMES = 64, MAX_SUGGESTIONS = 3;
|
|
2
|
+
const normalized = (name) => name.split("_").map(word => word.length > 4 ? word.replace(/ies$/, "y").replace(/s$/, "") : word).join("_");
|
|
3
|
+
function oneEditApart(left, right) {
|
|
4
|
+
if (Math.abs(left.length - right.length) > 1)
|
|
5
|
+
return false;
|
|
6
|
+
let a = 0, b = 0, edits = 0;
|
|
7
|
+
while (a < left.length && b < right.length) {
|
|
8
|
+
if (left[a] === right[b]) {
|
|
9
|
+
a++;
|
|
10
|
+
b++;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (++edits > 1)
|
|
14
|
+
return false;
|
|
15
|
+
if (left.length <= right.length)
|
|
16
|
+
b++;
|
|
17
|
+
if (left.length >= right.length)
|
|
18
|
+
a++;
|
|
19
|
+
}
|
|
20
|
+
return edits + Number(a < left.length || b < right.length) <= 1;
|
|
21
|
+
}
|
|
22
|
+
export class ToolNameHints {
|
|
23
|
+
names = new Set();
|
|
24
|
+
credential;
|
|
25
|
+
remember(names, credential) {
|
|
26
|
+
if (this.credential !== credential) {
|
|
27
|
+
this.names.clear();
|
|
28
|
+
this.credential = credential;
|
|
29
|
+
}
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
this.names.delete(name);
|
|
32
|
+
this.names.add(name);
|
|
33
|
+
if (this.names.size > MAX_REMEMBERED_NAMES) {
|
|
34
|
+
const oldest = this.names.values().next().value;
|
|
35
|
+
if (oldest !== undefined)
|
|
36
|
+
this.names.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
hint(requested, credential) {
|
|
41
|
+
if (credential !== this.credential)
|
|
42
|
+
return undefined;
|
|
43
|
+
const query = normalized(requested), prefix = requested.split("_")[0];
|
|
44
|
+
const found = [...this.names].filter(name => name !== requested && name.split("_")[0] === prefix && oneEditApart(query, normalized(name)))
|
|
45
|
+
.sort((a, b) => Number(normalized(a) !== query) - Number(normalized(b) !== query) || a.localeCompare(b)).slice(0, MAX_SUGGESTIONS);
|
|
46
|
+
return found.length ? `Loaded tool names: ${found.join(", ")}. Inspect the intended definition before quoting.` : undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { PLATFORM_WALLET_ROUTES } from "../schema/platformRoutes.js";
|
|
4
|
+
import { PLATFORM_WALLET_TOP_UP_MIN_CENTS } from "../schema/platformFunding.js";
|
|
5
|
+
import { checksumAddress, loadEvmWallet } from "./evm-wallet.js";
|
|
6
|
+
import { relvoApiUrl } from "./local-setup.js";
|
|
7
|
+
const MICROS_PER_CENT = 10_000;
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
9
|
+
const SIGN_IN_HEADER = "SIGN-IN-WITH-X";
|
|
10
|
+
const PAYMENT_REQUIRED_HEADER = "PAYMENT-REQUIRED";
|
|
11
|
+
const PAYMENT_SIGNATURE_HEADER = "PAYMENT-SIGNATURE";
|
|
12
|
+
const NETWORKS = {
|
|
13
|
+
"eip155:8453": { chainId: 8453, usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" },
|
|
14
|
+
"eip155:84532": { chainId: 84532, usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" },
|
|
15
|
+
};
|
|
16
|
+
const isNetwork = (value) => Object.hasOwn(NETWORKS, value);
|
|
17
|
+
export class WalletAccessError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(message, status) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.name = "WalletAccessError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/);
|
|
26
|
+
const resourceSchema = z.object({ url: z.string(), description: z.string().optional(), mimeType: z.string().optional() }).passthrough();
|
|
27
|
+
const requirementSchema = z.object({
|
|
28
|
+
scheme: z.string(), network: z.string(), amount: z.string().regex(/^\d+$/), asset: z.string(), payTo: z.string(),
|
|
29
|
+
maxTimeoutSeconds: z.number().int().positive(), extra: z.record(z.string(), z.unknown()).optional(),
|
|
30
|
+
}).passthrough();
|
|
31
|
+
const paymentRequiredSchema = z.object({
|
|
32
|
+
x402Version: z.literal(2), error: z.string().optional(), resource: resourceSchema, accepts: z.array(requirementSchema),
|
|
33
|
+
extensions: z.record(z.string(), z.unknown()).optional(),
|
|
34
|
+
}).passthrough();
|
|
35
|
+
const signInInfoSchema = z.object({
|
|
36
|
+
domain: z.string().min(1), uri: z.string().min(1), version: z.literal("1"), nonce: z.string().min(1), issuedAt: z.string().min(1),
|
|
37
|
+
expirationTime: z.string().optional(), notBefore: z.string().optional(), statement: z.string().optional(),
|
|
38
|
+
resources: z.array(z.string()).optional(), requestId: z.string().optional(),
|
|
39
|
+
}).passthrough();
|
|
40
|
+
const signInExtensionSchema = z.object({
|
|
41
|
+
info: signInInfoSchema, supportedChains: z.array(z.object({ chainId: z.string(), type: z.string() }).passthrough()),
|
|
42
|
+
}).passthrough();
|
|
43
|
+
const signInResultSchema = z.object({ data: z.object({
|
|
44
|
+
apiKey: z.string(), expiresAt: z.string(), address: z.string(), organizationId: z.string(), projectId: z.string(), created: z.boolean(),
|
|
45
|
+
}) });
|
|
46
|
+
const topUpResultSchema = z.object({ data: z.object({
|
|
47
|
+
creditedCents: z.number().int().positive(), transaction: z.string().nullable(), network: z.string(), payer: z.string(),
|
|
48
|
+
}) });
|
|
49
|
+
const base64Json = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
50
|
+
function api(apiUrl, local) {
|
|
51
|
+
const url = new URL(relvoApiUrl(apiUrl, local));
|
|
52
|
+
return { url, base: url.href.replace(/\/$/, "") };
|
|
53
|
+
}
|
|
54
|
+
async function serverMessage(response) {
|
|
55
|
+
try {
|
|
56
|
+
const body = z.object({ error: z.object({ message: z.string().regex(/^[a-z0-9_]{1,80}$/) }) }).safeParse(await response.json());
|
|
57
|
+
return body.success ? body.data.error.message : "no error code";
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return "no error code";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function send(fetchImpl, url, init, what) {
|
|
64
|
+
try {
|
|
65
|
+
return await fetchImpl(url, { ...init, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new WalletAccessError(`The Relvo API could not be reached for ${what}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function readPaymentRequired(response) {
|
|
72
|
+
const header = response.headers.get(PAYMENT_REQUIRED_HEADER);
|
|
73
|
+
let json;
|
|
74
|
+
try {
|
|
75
|
+
json = header ? JSON.parse(Buffer.from(header, "base64").toString("utf8")) : (await response.json()).paymentRequired;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new WalletAccessError("The Relvo API returned an unreadable payment requirement.", response.status);
|
|
79
|
+
}
|
|
80
|
+
const parsed = paymentRequiredSchema.safeParse(json);
|
|
81
|
+
if (!parsed.success)
|
|
82
|
+
throw new WalletAccessError("The Relvo API returned an unexpected payment requirement.", response.status);
|
|
83
|
+
return parsed.data;
|
|
84
|
+
}
|
|
85
|
+
function siweMessage(fields) {
|
|
86
|
+
const lines = [`${fields.domain} wants you to sign in with your Ethereum account:`, checksumAddress(fields.address), ""];
|
|
87
|
+
if (fields.statement)
|
|
88
|
+
lines.push(fields.statement, "");
|
|
89
|
+
lines.push(`URI: ${fields.uri}`, `Version: ${fields.version}`, `Chain ID: ${fields.chainId.replace(/^eip155:/, "")}`, `Nonce: ${fields.nonce}`, `Issued At: ${fields.issuedAt}`);
|
|
90
|
+
if (fields.expirationTime)
|
|
91
|
+
lines.push(`Expiration Time: ${fields.expirationTime}`);
|
|
92
|
+
if (fields.notBefore)
|
|
93
|
+
lines.push(`Not Before: ${fields.notBefore}`);
|
|
94
|
+
if (fields.requestId)
|
|
95
|
+
lines.push(`Request ID: ${fields.requestId}`);
|
|
96
|
+
if (fields.resources?.length)
|
|
97
|
+
lines.push("Resources:", ...fields.resources.map((resource) => `- ${resource}`));
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
export async function walletSignIn({ apiUrl, wallet, local, fetch: fetchImpl = fetch }) {
|
|
101
|
+
const { url, base } = api(apiUrl, local), endpoint = `${base}${PLATFORM_WALLET_ROUTES.signIn}`;
|
|
102
|
+
const challenge = await send(fetchImpl, endpoint, { method: "POST" }, "sign-in");
|
|
103
|
+
if (challenge.status !== 402)
|
|
104
|
+
throw new WalletAccessError(`Wallet sign-in is unavailable (${await serverMessage(challenge)}).`, challenge.status);
|
|
105
|
+
const extension = signInExtensionSchema.safeParse((await readPaymentRequired(challenge)).extensions?.["sign-in-with-x"]);
|
|
106
|
+
if (!extension.success)
|
|
107
|
+
throw new WalletAccessError("The Relvo API did not offer wallet sign-in.", 402);
|
|
108
|
+
const { info, supportedChains } = extension.data;
|
|
109
|
+
let uriOrigin;
|
|
110
|
+
try {
|
|
111
|
+
uriOrigin = new URL(info.uri).origin;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
uriOrigin = undefined;
|
|
115
|
+
}
|
|
116
|
+
if (info.domain !== url.host || uriOrigin !== url.origin)
|
|
117
|
+
throw new WalletAccessError("The sign-in challenge names a different site than the API URL; nothing was signed.");
|
|
118
|
+
if (info.expirationTime && Date.parse(info.expirationTime) <= Date.now())
|
|
119
|
+
throw new WalletAccessError("The sign-in challenge has already expired; nothing was signed.");
|
|
120
|
+
const chain = supportedChains.find((candidate) => candidate.type === "eip191" && isNetwork(candidate.chainId));
|
|
121
|
+
if (!chain)
|
|
122
|
+
throw new WalletAccessError("The sign-in challenge offers no supported chain.");
|
|
123
|
+
const fields = { ...info, address: wallet.address, chainId: chain.chainId };
|
|
124
|
+
const proof = { domain: info.domain, address: wallet.address, uri: info.uri, version: info.version, chainId: chain.chainId, type: "eip191", nonce: info.nonce,
|
|
125
|
+
issuedAt: info.issuedAt, ...(info.statement ? { statement: info.statement } : {}), ...(info.expirationTime ? { expirationTime: info.expirationTime } : {}),
|
|
126
|
+
...(info.notBefore ? { notBefore: info.notBefore } : {}), ...(info.requestId ? { requestId: info.requestId } : {}),
|
|
127
|
+
...(info.resources ? { resources: info.resources } : {}), signature: wallet.signPersonalMessage(siweMessage(fields)) };
|
|
128
|
+
const response = await send(fetchImpl, endpoint, { method: "POST", headers: { [SIGN_IN_HEADER]: base64Json(proof) } }, "sign-in");
|
|
129
|
+
if (!response.ok)
|
|
130
|
+
throw new WalletAccessError(`Wallet sign-in was rejected (${await serverMessage(response)}).`, response.status);
|
|
131
|
+
const parsed = signInResultSchema.safeParse(await response.json().catch(() => null));
|
|
132
|
+
if (!parsed.success)
|
|
133
|
+
throw new WalletAccessError("The Relvo API returned an unexpected sign-in response.", response.status);
|
|
134
|
+
return parsed.data.data;
|
|
135
|
+
}
|
|
136
|
+
export function walletAutoTopUp(options) {
|
|
137
|
+
let committedCents = 0;
|
|
138
|
+
return async (shortfall) => {
|
|
139
|
+
if (shortfall.reason !== "balance")
|
|
140
|
+
return null;
|
|
141
|
+
const deficit = BigInt(shortfall.requiredMicros) - BigInt(shortfall.availableMicros);
|
|
142
|
+
if (deficit <= 0n)
|
|
143
|
+
return null;
|
|
144
|
+
const amountCents = Math.max(PLATFORM_WALLET_TOP_UP_MIN_CENTS, Number((deficit + BigInt(MICROS_PER_CENT) - 1n) / BigInt(MICROS_PER_CENT)));
|
|
145
|
+
if (committedCents + amountCents > options.maximumTotalCents)
|
|
146
|
+
return null;
|
|
147
|
+
committedCents += amountCents;
|
|
148
|
+
return walletTopUp({ ...options, amountCents, maximumCents: amountCents });
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function walletAutoTopUpFromEnvironment(env, api) {
|
|
152
|
+
const key = env.RELVO_WALLET_PRIVATE_KEY?.trim(), cap = env.RELVO_WALLET_AUTO_TOP_UP_USD?.trim();
|
|
153
|
+
if (!key || !cap)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!/^\d{1,6}(?:\.\d{1,2})?$/.test(cap))
|
|
156
|
+
throw new WalletAccessError("RELVO_WALLET_AUTO_TOP_UP_USD must be a USD amount such as 5 or 5.00.");
|
|
157
|
+
const [dollars, cents = ""] = cap.split(".");
|
|
158
|
+
return walletAutoTopUp({ ...api, wallet: loadEvmWallet(key), maximumTotalCents: Number(dollars) * 100 + Number(cents.padEnd(2, "0")) });
|
|
159
|
+
}
|
|
160
|
+
export async function walletTopUp({ apiUrl, wallet, local, fetch: fetchImpl = fetch, apiKey, amountCents, maximumCents }) {
|
|
161
|
+
if (!Number.isSafeInteger(amountCents) || amountCents <= 0 || !Number.isSafeInteger(maximumCents) || maximumCents < amountCents)
|
|
162
|
+
throw new WalletAccessError("Use a positive whole-cent amount that does not exceed the maximum.");
|
|
163
|
+
const { base } = api(apiUrl, local), endpoint = `${base}${PLATFORM_WALLET_ROUTES.topUp}`;
|
|
164
|
+
const headers = { authorization: `Bearer ${apiKey}`, "content-type": "application/json" };
|
|
165
|
+
const body = JSON.stringify({ amountCents });
|
|
166
|
+
const quote = await send(fetchImpl, endpoint, { method: "POST", headers, body }, "top-up");
|
|
167
|
+
if (quote.status !== 402)
|
|
168
|
+
throw new WalletAccessError(`Top-up is unavailable (${await serverMessage(quote)}).`, quote.status);
|
|
169
|
+
const required = await readPaymentRequired(quote), accepted = required.accepts[0];
|
|
170
|
+
if (!accepted)
|
|
171
|
+
throw new WalletAccessError("The Relvo API offered no payment requirement.", 402);
|
|
172
|
+
if (accepted.scheme !== "exact")
|
|
173
|
+
throw new WalletAccessError("The payment requirement is not the exact scheme; nothing was signed.");
|
|
174
|
+
if (!isNetwork(accepted.network) || accepted.asset.toLowerCase() !== NETWORKS[accepted.network].usdc.toLowerCase())
|
|
175
|
+
throw new WalletAccessError("The payment requirement names an unknown network or token; nothing was signed.");
|
|
176
|
+
const micros = BigInt(accepted.amount);
|
|
177
|
+
if (micros > BigInt(maximumCents) * BigInt(MICROS_PER_CENT) || micros !== BigInt(amountCents) * BigInt(MICROS_PER_CENT))
|
|
178
|
+
throw new WalletAccessError("The payment requirement differs from the requested amount or exceeds the maximum; nothing was signed.");
|
|
179
|
+
if (!address.safeParse(accepted.payTo).success)
|
|
180
|
+
throw new WalletAccessError("The payment requirement has an invalid recipient; nothing was signed.");
|
|
181
|
+
const { name, version } = accepted.extra ?? {};
|
|
182
|
+
if (typeof name !== "string" || typeof version !== "string")
|
|
183
|
+
throw new WalletAccessError("The payment requirement lacks the token's signing domain; nothing was signed.");
|
|
184
|
+
const nonce = `0x${randomBytes(32).toString("hex")}`;
|
|
185
|
+
const authorization = { from: wallet.address, to: accepted.payTo, value: accepted.amount, validAfter: "0",
|
|
186
|
+
validBefore: String(Math.floor(Date.now() / 1000) + accepted.maxTimeoutSeconds), nonce };
|
|
187
|
+
const signature = wallet.signTypedData({ name, version, chainId: NETWORKS[accepted.network].chainId, verifyingContract: accepted.asset }, { TransferWithAuthorization: [
|
|
188
|
+
{ name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" },
|
|
189
|
+
{ name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" },
|
|
190
|
+
] }, "TransferWithAuthorization", authorization);
|
|
191
|
+
const payment = { x402Version: 2, resource: required.resource, accepted, payload: { signature, authorization } };
|
|
192
|
+
let response;
|
|
193
|
+
try {
|
|
194
|
+
response = await send(fetchImpl, endpoint, { method: "POST", headers: { ...headers, [PAYMENT_SIGNATURE_HEADER]: base64Json(payment) }, body }, "top-up");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
throw new WalletAccessError("The payment was signed but the reply was lost; it may have been settled. Check the balance with relvo usage before paying again.");
|
|
198
|
+
}
|
|
199
|
+
if (response.status === 402)
|
|
200
|
+
throw new WalletAccessError(`The payment was rejected (${(await readPaymentRequired(response)).error ?? "no reason given"}).`, 402);
|
|
201
|
+
if (!response.ok)
|
|
202
|
+
throw new WalletAccessError(`Top-up failed (${await serverMessage(response)}); the payment may be pending, so check the balance before paying again.`, response.status);
|
|
203
|
+
const parsed = topUpResultSchema.safeParse(await response.json().catch(() => null));
|
|
204
|
+
if (!parsed.success)
|
|
205
|
+
throw new WalletAccessError("The Relvo API returned an unexpected top-up response; check the balance before paying again.", response.status);
|
|
206
|
+
return parsed.data.data;
|
|
207
|
+
}
|