remesso-sdk 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +379 -2
- package/dist/abi.d.ts +63 -0
- package/dist/abi.js +14 -0
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +39 -0
- package/dist/attribution.d.ts +3 -0
- package/dist/attribution.js +29 -0
- package/dist/client.d.ts +75 -0
- package/dist/client.js +60 -0
- package/dist/context.d.ts +42 -0
- package/dist/context.js +157 -0
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.js +80 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +10 -0
- package/dist/events.d.ts +59 -0
- package/dist/events.js +53 -0
- package/dist/generated/abi.d.ts +1543 -0
- package/dist/generated/abi.js +1979 -0
- package/dist/generated/deployment.d.ts +114 -0
- package/dist/generated/deployment.js +127 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/links.d.ts +25 -0
- package/dist/links.js +60 -0
- package/dist/node.d.ts +5 -0
- package/dist/node.js +60 -0
- package/dist/payments.d.ts +39 -0
- package/dist/payments.js +232 -0
- package/dist/policies.d.ts +54 -0
- package/dist/policies.js +148 -0
- package/dist/requests.d.ts +103 -0
- package/dist/requests.js +145 -0
- package/dist/schedules.d.ts +111 -0
- package/dist/schedules.js +232 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +19 -0
- package/dist/vendor/erc8021.d.ts +9 -0
- package/dist/vendor/erc8021.js +82 -0
- package/dist/vendor/fee.d.ts +3 -0
- package/dist/vendor/fee.js +40 -0
- package/dist/vendor/payee-profile.d.ts +27 -0
- package/dist/vendor/payee-profile.js +73 -0
- package/dist/vendor/request-decline.d.ts +55 -0
- package/dist/vendor/request-decline.js +60 -0
- package/dist/vendor/safe-fetch.d.ts +2 -0
- package/dist/vendor/safe-fetch.js +154 -0
- package/dist/vendor/standard-webhooks.d.ts +8 -0
- package/dist/vendor/standard-webhooks.js +83 -0
- package/dist/vendor/webhook-actions.d.ts +84 -0
- package/dist/vendor/webhook-actions.js +123 -0
- package/dist/webhooks.d.ts +88 -0
- package/dist/webhooks.js +113 -0
- package/package.json +58 -4
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copied from web/lib/fee.ts by scripts/build-sdk.ts. Edit the original and re-run;
|
|
2
|
+
// `build-sdk.ts --check` fails while the two differ.
|
|
3
|
+
/// The commission V5 takes, and what a recipient is owed, in base units.
|
|
4
|
+
///
|
|
5
|
+
/// V5 rounds its commission UP (`Math.mulDiv(amount, bps, 10_000,
|
|
6
|
+
/// Math.Rounding.Ceil)`), so what arrives from a gross amount is
|
|
7
|
+
/// `gross - ceil(gross * bps / 10_000)`. A requested amount, from a request or
|
|
8
|
+
/// a pay link, is what the recipient receives (docs/agent-to-agent.md, 3a,
|
|
9
|
+
/// decided 2026-10-24), so the form signs `grossFor(requested)`: an invoice
|
|
10
|
+
/// names what is owed, and the payer covers the fee on top.
|
|
11
|
+
///
|
|
12
|
+
/// A policy payment through RemessoAgentAllowance takes no fee, so none of
|
|
13
|
+
/// this applies there: what an agent pays is what arrives.
|
|
14
|
+
/// What V5 keeps from `gross`, rounded up as the contract rounds it.
|
|
15
|
+
export function feeOf(gross, bps) {
|
|
16
|
+
return (gross * BigInt(bps) + 9999n) / 10000n;
|
|
17
|
+
}
|
|
18
|
+
/// What the recipient receives from `gross`.
|
|
19
|
+
export function netOf(gross, bps) {
|
|
20
|
+
return gross - feeOf(gross, bps);
|
|
21
|
+
}
|
|
22
|
+
/// The smallest gross whose net is at least `net`.
|
|
23
|
+
///
|
|
24
|
+
/// `netOf` never decreases as the gross grows (each extra unit raises the fee
|
|
25
|
+
/// by at most one), so starting from the estimate `net / (1 - bps/10_000)` and
|
|
26
|
+
/// stepping a unit or two either way finds the exact answer.
|
|
27
|
+
export function grossFor(net, bps) {
|
|
28
|
+
if (net <= 0n)
|
|
29
|
+
return 0n;
|
|
30
|
+
if (!Number.isInteger(bps) || bps < 0 || bps >= 10_000)
|
|
31
|
+
throw new Error(`fee of ${bps}bps is out of range`);
|
|
32
|
+
if (bps === 0)
|
|
33
|
+
return net;
|
|
34
|
+
let gross = (net * 10000n) / (10000n - BigInt(bps));
|
|
35
|
+
while (gross > 0n && netOf(gross - 1n, bps) >= net)
|
|
36
|
+
gross--;
|
|
37
|
+
while (netOf(gross, bps) < net)
|
|
38
|
+
gross++;
|
|
39
|
+
return gross;
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type PayeeProfile = {
|
|
2
|
+
version: "1";
|
|
3
|
+
endpoint: string | null;
|
|
4
|
+
assets: {
|
|
5
|
+
network: string;
|
|
6
|
+
asset: string;
|
|
7
|
+
}[];
|
|
8
|
+
reference: {
|
|
9
|
+
required: boolean;
|
|
10
|
+
} | null;
|
|
11
|
+
};
|
|
12
|
+
export type UriSource = {
|
|
13
|
+
kind: "inline";
|
|
14
|
+
json: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "fetch";
|
|
17
|
+
url: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "unsupported";
|
|
20
|
+
reason: string;
|
|
21
|
+
};
|
|
22
|
+
export declare const IPFS_GATEWAY = "https://ipfs.io/ipfs/";
|
|
23
|
+
export declare function uriSource(uri: string): UriSource;
|
|
24
|
+
export declare function payeeProfile(doc: unknown): {
|
|
25
|
+
profile: PayeeProfile | null;
|
|
26
|
+
note?: string;
|
|
27
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Copied from web/lib/payee-profile.ts by scripts/build-sdk.ts. Edit the original and re-run;
|
|
2
|
+
// `build-sdk.ts --check` fails while the two differ.
|
|
3
|
+
/// An agent's payee profile: the `remesso` entry in the `services` list of its
|
|
4
|
+
/// ERC-8004 registration file (docs/agent-to-agent.md, 3d; docs/AGENTS.md).
|
|
5
|
+
///
|
|
6
|
+
/// It says which tokens the agent accepts and whether a payment must carry a
|
|
7
|
+
/// reference. It never says where to pay: the registration file can be
|
|
8
|
+
/// rewritten by the token's owner at any time, so the address comes only from
|
|
9
|
+
/// the registry's `getAgentWallet`, which only the wallet's own signature can
|
|
10
|
+
/// set. Nothing here returns an address to pay.
|
|
11
|
+
///
|
|
12
|
+
/// Pure, so both servers read files the same way. The local MCP wallet carries
|
|
13
|
+
/// a copy (`mcp/src/payee-profile.mjs`).
|
|
14
|
+
export const IPFS_GATEWAY = "https://ipfs.io/ipfs/";
|
|
15
|
+
export function uriSource(uri) {
|
|
16
|
+
const u = uri.trim();
|
|
17
|
+
if (u.startsWith("data:")) {
|
|
18
|
+
const comma = u.indexOf(",");
|
|
19
|
+
if (comma < 0)
|
|
20
|
+
return { kind: "unsupported", reason: "a data: URI without data" };
|
|
21
|
+
const meta = u.slice(5, comma);
|
|
22
|
+
const body = u.slice(comma + 1);
|
|
23
|
+
try {
|
|
24
|
+
const json = /;base64$/i.test(meta)
|
|
25
|
+
? new TextDecoder().decode(Uint8Array.from(atob(body), (c) => c.charCodeAt(0)))
|
|
26
|
+
: decodeURIComponent(body);
|
|
27
|
+
return { kind: "inline", json };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { kind: "unsupported", reason: "a data: URI that doesn't decode" };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (u.startsWith("https://"))
|
|
34
|
+
return { kind: "fetch", url: u };
|
|
35
|
+
if (u.startsWith("ipfs://")) {
|
|
36
|
+
const path = u.slice(7).replace(/^ipfs\//, "");
|
|
37
|
+
if (!/^[a-zA-Z0-9]+(\/[^\s?#]*)?$/.test(path))
|
|
38
|
+
return { kind: "unsupported", reason: "an ipfs: URI that isn't a CID" };
|
|
39
|
+
return { kind: "fetch", url: `${IPFS_GATEWAY}${path}` };
|
|
40
|
+
}
|
|
41
|
+
return { kind: "unsupported", reason: `${u.split(":")[0] || "an empty"} URIs aren't read` };
|
|
42
|
+
}
|
|
43
|
+
const CAIP2 = /^eip155:\d{1,12}$/;
|
|
44
|
+
const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
45
|
+
/// The `remesso` entry of a registration file, under the reader's rules:
|
|
46
|
+
/// unknown fields are ignored, and a version this reader doesn't implement
|
|
47
|
+
/// counts as no profile at all.
|
|
48
|
+
export function payeeProfile(doc) {
|
|
49
|
+
if (!doc || typeof doc !== "object")
|
|
50
|
+
return { profile: null, note: "the registration file isn't a JSON object" };
|
|
51
|
+
const d = doc;
|
|
52
|
+
// `services` replaced `endpoints` in the spec on 2026-01-25; older files use the old name.
|
|
53
|
+
const list = Array.isArray(d.services) ? d.services : Array.isArray(d.endpoints) ? d.endpoints : [];
|
|
54
|
+
const entry = list.find((s) => s && typeof s === "object" && s.name === "remesso");
|
|
55
|
+
if (!entry)
|
|
56
|
+
return { profile: null, note: "no remesso entry in its services" };
|
|
57
|
+
if (String(entry.version ?? "") !== "1") {
|
|
58
|
+
return { profile: null, note: `a remesso entry of version ${String(entry.version ?? "none")}, which this reader doesn't implement` };
|
|
59
|
+
}
|
|
60
|
+
const assets = (Array.isArray(entry.assets) ? entry.assets : [])
|
|
61
|
+
.filter((a) => !!a && typeof a === "object" && CAIP2.test(String(a.network)) &&
|
|
62
|
+
ADDRESS.test(String(a.asset)))
|
|
63
|
+
.map((a) => ({ network: a.network, asset: a.asset }));
|
|
64
|
+
const ref = entry.reference;
|
|
65
|
+
return {
|
|
66
|
+
profile: {
|
|
67
|
+
version: "1",
|
|
68
|
+
endpoint: typeof entry.endpoint === "string" && entry.endpoint.startsWith("https://") ? entry.endpoint : null,
|
|
69
|
+
assets,
|
|
70
|
+
reference: ref && typeof ref.required === "boolean" ? { required: ref.required } : null,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare const DECLINE_DOMAIN: {
|
|
2
|
+
readonly name: "Remesso";
|
|
3
|
+
readonly version: "1";
|
|
4
|
+
readonly chainId: 42220;
|
|
5
|
+
};
|
|
6
|
+
export declare const DECLINE_TYPES: {
|
|
7
|
+
readonly PaymentRequestAction: readonly [{
|
|
8
|
+
readonly name: "request";
|
|
9
|
+
readonly type: "string";
|
|
10
|
+
}, {
|
|
11
|
+
readonly name: "action";
|
|
12
|
+
readonly type: "string";
|
|
13
|
+
}, {
|
|
14
|
+
readonly name: "expiry";
|
|
15
|
+
readonly type: "uint64";
|
|
16
|
+
}];
|
|
17
|
+
};
|
|
18
|
+
export declare const MAX_DECLINE_TTL_SECS: number;
|
|
19
|
+
export type Decline = {
|
|
20
|
+
id: string;
|
|
21
|
+
expiry: number;
|
|
22
|
+
signature: `0x${string}`;
|
|
23
|
+
};
|
|
24
|
+
export declare function declineTypedData(id: string, expiry: number): {
|
|
25
|
+
domain: {
|
|
26
|
+
readonly name: "Remesso";
|
|
27
|
+
readonly version: "1";
|
|
28
|
+
readonly chainId: 42220;
|
|
29
|
+
};
|
|
30
|
+
types: {
|
|
31
|
+
readonly PaymentRequestAction: readonly [{
|
|
32
|
+
readonly name: "request";
|
|
33
|
+
readonly type: "string";
|
|
34
|
+
}, {
|
|
35
|
+
readonly name: "action";
|
|
36
|
+
readonly type: "string";
|
|
37
|
+
}, {
|
|
38
|
+
readonly name: "expiry";
|
|
39
|
+
readonly type: "uint64";
|
|
40
|
+
}];
|
|
41
|
+
};
|
|
42
|
+
primaryType: "PaymentRequestAction";
|
|
43
|
+
message: {
|
|
44
|
+
request: string;
|
|
45
|
+
action: string;
|
|
46
|
+
expiry: bigint;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export declare function checkDecline(body: Record<string, unknown>, nowSecs: number): {
|
|
50
|
+
ok: true;
|
|
51
|
+
decline: Decline;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
error: string;
|
|
55
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Copied from supabase/functions/_shared/request-decline.ts by scripts/build-sdk.ts. Edit the original and re-run;
|
|
2
|
+
// `build-sdk.ts --check` fails while the two differ.
|
|
3
|
+
/// A payer declining a payment request, by signature.
|
|
4
|
+
///
|
|
5
|
+
/// The app can't decline for anyone. An anonymous session's claimed wallet
|
|
6
|
+
/// proves nothing and MiniPay can't sign, so a dismissal in the app only hides
|
|
7
|
+
/// a request for that session. An agent holds a key, so it can sign a decline,
|
|
8
|
+
/// and a signed decline is the only kind a requester is ever told about
|
|
9
|
+
/// (docs/agent-to-agent.md, 1d and step 3).
|
|
10
|
+
///
|
|
11
|
+
/// Pure: the typed data, and the checks that need neither the database nor
|
|
12
|
+
/// the chain. `request-payment` verifies the signature with
|
|
13
|
+
/// `publicClient.verifyTypedData`, which also handles ERC-1271 smart accounts;
|
|
14
|
+
/// the tests verify offline. The SDK imports this same definition, so a payer
|
|
15
|
+
/// signs exactly what we verify.
|
|
16
|
+
/// No verifying contract: nothing on-chain checks this signature. The request
|
|
17
|
+
/// id, the action and the expiry are what bind it.
|
|
18
|
+
export const DECLINE_DOMAIN = { name: "Remesso", version: "1", chainId: 42220 };
|
|
19
|
+
export const DECLINE_TYPES = {
|
|
20
|
+
PaymentRequestAction: [
|
|
21
|
+
{ name: "request", type: "string" },
|
|
22
|
+
{ name: "action", type: "string" },
|
|
23
|
+
{ name: "expiry", type: "uint64" },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
/// How far ahead a signed decline may expire. Long enough for an agent to sign
|
|
27
|
+
/// it and send it, short enough that a signature found later is worthless.
|
|
28
|
+
export const MAX_DECLINE_TTL_SECS = 24 * 60 * 60;
|
|
29
|
+
/// The message a payer signs to decline request `id`. The id is signed as the
|
|
30
|
+
/// caller sends it; request-payment returns it lowercase.
|
|
31
|
+
export function declineTypedData(id, expiry) {
|
|
32
|
+
return {
|
|
33
|
+
domain: DECLINE_DOMAIN,
|
|
34
|
+
types: DECLINE_TYPES,
|
|
35
|
+
primaryType: "PaymentRequestAction",
|
|
36
|
+
message: { request: id, action: "decline", expiry: BigInt(expiry) },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
40
|
+
/// 65 bytes for a plain key; a smart account's ERC-1271 signature can be
|
|
41
|
+
/// longer, so only an absurd size is refused.
|
|
42
|
+
const SIGNATURE = /^0x(?:[0-9a-fA-F]{2}){65,4096}$/;
|
|
43
|
+
/// Everything about a decline that can be checked without the database or the
|
|
44
|
+
/// chain. `nowSecs` is a parameter so tests can hold the clock still.
|
|
45
|
+
export function checkDecline(body, nowSecs) {
|
|
46
|
+
const id = String(body.id ?? "");
|
|
47
|
+
if (!UUID.test(id))
|
|
48
|
+
return { ok: false, error: "id must be a payment request id" };
|
|
49
|
+
const expiry = Number(body.expiry);
|
|
50
|
+
if (!Number.isSafeInteger(expiry))
|
|
51
|
+
return { ok: false, error: "expiry must be unix seconds" };
|
|
52
|
+
if (expiry <= nowSecs)
|
|
53
|
+
return { ok: false, error: "this signed decline has expired" };
|
|
54
|
+
if (expiry > nowSecs + MAX_DECLINE_TTL_SECS)
|
|
55
|
+
return { ok: false, error: "expiry must be within a day" };
|
|
56
|
+
const signature = String(body.signature ?? "");
|
|
57
|
+
if (!SIGNATURE.test(signature))
|
|
58
|
+
return { ok: false, error: "signature must be a hex signature" };
|
|
59
|
+
return { ok: true, decline: { id, expiry, signature: signature } };
|
|
60
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { request } from "node:https";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
function v4Octets(ip) {
|
|
5
|
+
const parts = ip.split(".");
|
|
6
|
+
if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p)))
|
|
7
|
+
return null;
|
|
8
|
+
const o = parts.map(Number);
|
|
9
|
+
return o.every((n) => n <= 255) ? o : null;
|
|
10
|
+
}
|
|
11
|
+
function v4Public([a, b, c]) {
|
|
12
|
+
if (a === 0 || a === 10 || a === 127)
|
|
13
|
+
return false; // this network, private, loopback
|
|
14
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
15
|
+
return false; // CGNAT
|
|
16
|
+
if (a === 169 && b === 254)
|
|
17
|
+
return false; // link-local, cloud metadata
|
|
18
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
19
|
+
return false; // private
|
|
20
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2))
|
|
21
|
+
return false; // protocol assignments, documentation
|
|
22
|
+
if (a === 192 && b === 88 && c === 99)
|
|
23
|
+
return false; // 6to4 relay
|
|
24
|
+
if (a === 192 && b === 168)
|
|
25
|
+
return false; // private
|
|
26
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
27
|
+
return false; // benchmarking
|
|
28
|
+
if (a === 198 && b === 51 && c === 100)
|
|
29
|
+
return false; // documentation
|
|
30
|
+
if (a === 203 && b === 0 && c === 113)
|
|
31
|
+
return false; // documentation
|
|
32
|
+
return a < 224; // multicast, reserved and broadcast above
|
|
33
|
+
}
|
|
34
|
+
/// Eight 16-bit groups, or null. Handles `::`, a zone id and a dotted-quad tail.
|
|
35
|
+
function v6Groups(raw) {
|
|
36
|
+
let s = raw.split("%")[0].toLowerCase();
|
|
37
|
+
const last = s.lastIndexOf(":");
|
|
38
|
+
const tail = s.slice(last + 1);
|
|
39
|
+
if (tail.includes(".")) {
|
|
40
|
+
const o = v4Octets(tail);
|
|
41
|
+
if (!o)
|
|
42
|
+
return null;
|
|
43
|
+
s = `${s.slice(0, last + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
|
44
|
+
}
|
|
45
|
+
const halves = s.split("::");
|
|
46
|
+
if (halves.length > 2)
|
|
47
|
+
return null;
|
|
48
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
49
|
+
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
50
|
+
const fill = halves.length === 2 ? 8 - head.length - rest.length : 0;
|
|
51
|
+
if (fill < 0 || (halves.length === 2 && fill < 1))
|
|
52
|
+
return null;
|
|
53
|
+
const groups = [...head, ...Array(fill).fill("0"), ...rest];
|
|
54
|
+
if (groups.length !== 8 || !groups.every((g) => /^[0-9a-f]{1,4}$/.test(g)))
|
|
55
|
+
return null;
|
|
56
|
+
return groups.map((g) => parseInt(g, 16));
|
|
57
|
+
}
|
|
58
|
+
function v6Public(g) {
|
|
59
|
+
const zero = (n) => g.slice(0, n).every((x) => x === 0);
|
|
60
|
+
const inner = () => [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255];
|
|
61
|
+
if (zero(8))
|
|
62
|
+
return false; // ::
|
|
63
|
+
if (zero(7) && g[7] === 1)
|
|
64
|
+
return false; // ::1
|
|
65
|
+
if (zero(5) && g[5] === 0xffff)
|
|
66
|
+
return v4Public(inner()); // ::ffff:a.b.c.d
|
|
67
|
+
if (zero(6))
|
|
68
|
+
return v4Public(inner()); // ::a.b.c.d, deprecated
|
|
69
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0))
|
|
70
|
+
return v4Public(inner()); // NAT64
|
|
71
|
+
if (g[0] === 0x100 && g.slice(1, 4).every((x) => x === 0))
|
|
72
|
+
return false; // 100::/64, discard-only
|
|
73
|
+
if (g[0] === 0x2001 && (g[1] === 0x0db8 || g[1] === 0))
|
|
74
|
+
return false; // documentation, Teredo
|
|
75
|
+
if (g[0] === 0x2002)
|
|
76
|
+
return false; // 6to4 carries an IPv4 we'd have to trust
|
|
77
|
+
if ((g[0] & 0xfe00) === 0xfc00)
|
|
78
|
+
return false; // unique local
|
|
79
|
+
if ((g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0)
|
|
80
|
+
return false; // link-local, site-local
|
|
81
|
+
return (g[0] & 0xff00) !== 0xff00; // multicast
|
|
82
|
+
}
|
|
83
|
+
/// Whether an address is safe to connect to: public, in either family.
|
|
84
|
+
export function isPublicIp(ip) {
|
|
85
|
+
if (ip.includes(":")) {
|
|
86
|
+
const g = v6Groups(ip.replace(/^\[|\]$/g, ""));
|
|
87
|
+
return g !== null && v6Public(g);
|
|
88
|
+
}
|
|
89
|
+
const o = v4Octets(ip);
|
|
90
|
+
return o !== null && v4Public(o);
|
|
91
|
+
}
|
|
92
|
+
const MAX_BYTES = 256 * 1024;
|
|
93
|
+
const TIMEOUT_MS = 5_000;
|
|
94
|
+
/// The body of `raw`, as text, under the rules above. Throws, naming the rule,
|
|
95
|
+
/// on anything else.
|
|
96
|
+
export async function safeFetchText(raw) {
|
|
97
|
+
const url = new URL(raw);
|
|
98
|
+
if (url.protocol !== "https:")
|
|
99
|
+
throw new Error("only https URLs are fetched");
|
|
100
|
+
if (url.username || url.password)
|
|
101
|
+
throw new Error("a URL with credentials in it is refused");
|
|
102
|
+
if (url.port && url.port !== "443")
|
|
103
|
+
throw new Error("only the default HTTPS port is fetched");
|
|
104
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
105
|
+
const found = isIP(host)
|
|
106
|
+
? [{ address: host, family: isIP(host) }]
|
|
107
|
+
: await lookup(host, { all: true, verbatim: true });
|
|
108
|
+
if (!found.length)
|
|
109
|
+
throw new Error(`${host} doesn't resolve`);
|
|
110
|
+
const bad = found.find((a) => !isPublicIp(a.address));
|
|
111
|
+
if (bad)
|
|
112
|
+
throw new Error(`${host} resolves to ${bad.address}, which isn't a public address`);
|
|
113
|
+
const pinned = found[0];
|
|
114
|
+
// The connection goes to the address just checked. Newer Node asks for every
|
|
115
|
+
// address at once (`all`), older for one.
|
|
116
|
+
const pin = (_name, opts, cb) => {
|
|
117
|
+
if (opts.all)
|
|
118
|
+
cb(null, [pinned]);
|
|
119
|
+
else
|
|
120
|
+
cb(null, pinned.address, pinned.family);
|
|
121
|
+
};
|
|
122
|
+
return await new Promise((resolve, reject) => {
|
|
123
|
+
const req = request(url, {
|
|
124
|
+
method: "GET",
|
|
125
|
+
headers: { accept: "application/json, text/plain;q=0.5", "user-agent": "remesso-find-agent" },
|
|
126
|
+
lookup: pin,
|
|
127
|
+
}, (res) => {
|
|
128
|
+
const status = res.statusCode ?? 0;
|
|
129
|
+
if (status < 200 || status >= 300) {
|
|
130
|
+
res.resume();
|
|
131
|
+
reject(new Error(`${host} answered ${status}${status >= 300 && status < 400 ? ", a redirect, which isn't followed" : ""}`));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const chunks = [];
|
|
135
|
+
let size = 0;
|
|
136
|
+
res.on("data", (c) => {
|
|
137
|
+
size += c.length;
|
|
138
|
+
if (size > MAX_BYTES)
|
|
139
|
+
req.destroy(new Error(`${host} sent more than ${MAX_BYTES / 1024} KB`));
|
|
140
|
+
else
|
|
141
|
+
chunks.push(c);
|
|
142
|
+
});
|
|
143
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
144
|
+
res.on("error", reject);
|
|
145
|
+
});
|
|
146
|
+
const timer = setTimeout(() => req.destroy(new Error(`${host} didn't answer within ${TIMEOUT_MS / 1000}s`)), TIMEOUT_MS);
|
|
147
|
+
req.on("error", (e) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
reject(e);
|
|
150
|
+
});
|
|
151
|
+
req.on("close", () => clearTimeout(timer));
|
|
152
|
+
req.end();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const TOLERANCE_SECONDS: number;
|
|
2
|
+
export declare function generateSecret(): string;
|
|
3
|
+
export declare function sign(secret: string, id: string, timestampSeconds: number, body: string): Promise<string>;
|
|
4
|
+
export declare function signedHeaders(secrets: string[], id: string, body: string, timestampSeconds?: number): Promise<Record<string, string>>;
|
|
5
|
+
export declare function verify(raw: string, headers: Headers | Record<string, string>, secret: string, { nowSeconds, toleranceSeconds }?: {
|
|
6
|
+
nowSeconds?: number | undefined;
|
|
7
|
+
toleranceSeconds?: number | undefined;
|
|
8
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Copied from supabase/functions/_shared/standard-webhooks.ts by scripts/build-sdk.ts. Edit the original and re-run;
|
|
2
|
+
// `build-sdk.ts --check` fails while the two differ.
|
|
3
|
+
/// Standard Webhooks (standardwebhooks.com), the scheme Svix speaks. Self signs
|
|
4
|
+
/// its deliveries to us with it, and Remesso signs its own deliveries with it.
|
|
5
|
+
///
|
|
6
|
+
/// signed = `${webhook-id}.${webhook-timestamp}.${body}`
|
|
7
|
+
/// key = base64-decode(secret without its `whsec_` prefix)
|
|
8
|
+
/// signature = base64(HMAC-SHA256(key, signed)), sent as `v1,<sig>`,
|
|
9
|
+
/// space-separated when a secret is mid-rotation
|
|
10
|
+
///
|
|
11
|
+
/// The timestamp window is what stops a captured delivery being replayed
|
|
12
|
+
/// later. Web Crypto and nothing else, so the SDK imports this file as it is,
|
|
13
|
+
/// in Node, Deno, Bun or a browser. `standard-webhooks.test.ts` holds it to the
|
|
14
|
+
/// `svix` and `standardwebhooks` libraries.
|
|
15
|
+
export const TOLERANCE_SECONDS = 5 * 60;
|
|
16
|
+
/// A fresh signing secret: 32 random bytes as `whsec_`-prefixed base64.
|
|
17
|
+
export function generateSecret() {
|
|
18
|
+
return `whsec_${bytesToBase64(crypto.getRandomValues(new Uint8Array(32)))}`;
|
|
19
|
+
}
|
|
20
|
+
/// `v1,<signature>` of `body` under one secret. Throws on a malformed secret:
|
|
21
|
+
/// that is the sender's mistake, not something to swallow.
|
|
22
|
+
export async function sign(secret, id, timestampSeconds, body) {
|
|
23
|
+
const key = await hmacKey(secret);
|
|
24
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${timestampSeconds}.${body}`));
|
|
25
|
+
return `v1,${bytesToBase64(new Uint8Array(mac))}`;
|
|
26
|
+
}
|
|
27
|
+
/// The three headers for one attempt at a delivery. With more than one secret
|
|
28
|
+
/// (a rotation in progress) there is one signature per secret, so a receiver
|
|
29
|
+
/// holding either one verifies. The id stays the same on every attempt; the
|
|
30
|
+
/// timestamp, and so the signature, are new each time.
|
|
31
|
+
export async function signedHeaders(secrets, id, body, timestampSeconds = Math.floor(Date.now() / 1000)) {
|
|
32
|
+
const signatures = await Promise.all(secrets.map((s) => sign(s, id, timestampSeconds, body)));
|
|
33
|
+
return {
|
|
34
|
+
"webhook-id": id,
|
|
35
|
+
"webhook-timestamp": String(timestampSeconds),
|
|
36
|
+
"webhook-signature": signatures.join(" "),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/// Whether `raw` was signed with `secret` within the tolerance. Reads the
|
|
40
|
+
/// `webhook-*` headers, or the `svix-*` names Svix sends the same values
|
|
41
|
+
/// under. False, never a throw, for anything malformed.
|
|
42
|
+
export async function verify(raw, headers, secret, { nowSeconds = Math.floor(Date.now() / 1000), toleranceSeconds = TOLERANCE_SECONDS } = {}) {
|
|
43
|
+
const get = (name) => headers instanceof Headers ? headers.get(name) : headers[name] ?? headers[name.toLowerCase()] ?? null;
|
|
44
|
+
const id = get("webhook-id") ?? get("svix-id");
|
|
45
|
+
const ts = get("webhook-timestamp") ?? get("svix-timestamp");
|
|
46
|
+
const sigs = get("webhook-signature") ?? get("svix-signature");
|
|
47
|
+
if (!id || !ts || !sigs || !secret)
|
|
48
|
+
return false;
|
|
49
|
+
const sent = Number(ts);
|
|
50
|
+
if (!Number.isInteger(sent) || Math.abs(nowSeconds - sent) > toleranceSeconds)
|
|
51
|
+
return false;
|
|
52
|
+
let expected;
|
|
53
|
+
try {
|
|
54
|
+
expected = (await sign(secret, id, sent, raw)).slice(3);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return sigs.split(" ").some((part) => {
|
|
60
|
+
const [version, sig] = part.split(",", 2);
|
|
61
|
+
return version === "v1" && sig !== undefined && timingSafeEqual(expected, sig);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function hmacKey(secret) {
|
|
65
|
+
const bytes = Uint8Array.from(atob(secret.replace(/^whsec_/, "")), (c) => c.charCodeAt(0));
|
|
66
|
+
if (!bytes.length)
|
|
67
|
+
throw new Error("webhook secret is empty");
|
|
68
|
+
return await crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
69
|
+
}
|
|
70
|
+
function bytesToBase64(bytes) {
|
|
71
|
+
let s = "";
|
|
72
|
+
for (const b of bytes)
|
|
73
|
+
s += String.fromCharCode(b);
|
|
74
|
+
return btoa(s);
|
|
75
|
+
}
|
|
76
|
+
function timingSafeEqual(a, b) {
|
|
77
|
+
if (a.length !== b.length)
|
|
78
|
+
return false;
|
|
79
|
+
let diff = 0;
|
|
80
|
+
for (let i = 0; i < a.length; i++)
|
|
81
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
82
|
+
return diff === 0;
|
|
83
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export declare const WEBHOOK_DOMAIN: {
|
|
2
|
+
readonly name: "Remesso";
|
|
3
|
+
readonly version: "1";
|
|
4
|
+
readonly chainId: 42220;
|
|
5
|
+
};
|
|
6
|
+
export declare const WEBHOOK_TYPES: {
|
|
7
|
+
readonly WebhookAction: readonly [{
|
|
8
|
+
readonly name: "recipient";
|
|
9
|
+
readonly type: "address";
|
|
10
|
+
}, {
|
|
11
|
+
readonly name: "url";
|
|
12
|
+
readonly type: "string";
|
|
13
|
+
}, {
|
|
14
|
+
readonly name: "chainId";
|
|
15
|
+
readonly type: "uint256";
|
|
16
|
+
}, {
|
|
17
|
+
readonly name: "action";
|
|
18
|
+
readonly type: "string";
|
|
19
|
+
}, {
|
|
20
|
+
readonly name: "nonce";
|
|
21
|
+
readonly type: "string";
|
|
22
|
+
}, {
|
|
23
|
+
readonly name: "expiry";
|
|
24
|
+
readonly type: "uint64";
|
|
25
|
+
}];
|
|
26
|
+
};
|
|
27
|
+
export declare const WEBHOOK_ACTIONS: readonly ["subscribe", "rotate", "revoke", "replay"];
|
|
28
|
+
export type WebhookActionName = (typeof WEBHOOK_ACTIONS)[number];
|
|
29
|
+
export declare const MAX_ACTION_TTL_SECS: number;
|
|
30
|
+
export declare const MAX_URL_LENGTH = 512;
|
|
31
|
+
export type WebhookAction = {
|
|
32
|
+
recipient: `0x${string}`;
|
|
33
|
+
url: string;
|
|
34
|
+
chainId: number;
|
|
35
|
+
action: WebhookActionName;
|
|
36
|
+
nonce: string;
|
|
37
|
+
expiry: number;
|
|
38
|
+
signature: `0x${string}`;
|
|
39
|
+
};
|
|
40
|
+
export declare function webhookTypedData(a: Omit<WebhookAction, "signature">): {
|
|
41
|
+
domain: {
|
|
42
|
+
readonly name: "Remesso";
|
|
43
|
+
readonly version: "1";
|
|
44
|
+
readonly chainId: 42220;
|
|
45
|
+
};
|
|
46
|
+
types: {
|
|
47
|
+
readonly WebhookAction: readonly [{
|
|
48
|
+
readonly name: "recipient";
|
|
49
|
+
readonly type: "address";
|
|
50
|
+
}, {
|
|
51
|
+
readonly name: "url";
|
|
52
|
+
readonly type: "string";
|
|
53
|
+
}, {
|
|
54
|
+
readonly name: "chainId";
|
|
55
|
+
readonly type: "uint256";
|
|
56
|
+
}, {
|
|
57
|
+
readonly name: "action";
|
|
58
|
+
readonly type: "string";
|
|
59
|
+
}, {
|
|
60
|
+
readonly name: "nonce";
|
|
61
|
+
readonly type: "string";
|
|
62
|
+
}, {
|
|
63
|
+
readonly name: "expiry";
|
|
64
|
+
readonly type: "uint64";
|
|
65
|
+
}];
|
|
66
|
+
};
|
|
67
|
+
primaryType: "WebhookAction";
|
|
68
|
+
message: {
|
|
69
|
+
recipient: `0x${string}`;
|
|
70
|
+
url: string;
|
|
71
|
+
chainId: bigint;
|
|
72
|
+
action: "revoke" | "subscribe" | "rotate" | "replay";
|
|
73
|
+
nonce: string;
|
|
74
|
+
expiry: bigint;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
export declare function endpointProblem(url: string): string | null;
|
|
78
|
+
export declare function checkAction(body: Record<string, unknown>, nowSecs: number): {
|
|
79
|
+
ok: true;
|
|
80
|
+
action: WebhookAction;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
error: string;
|
|
84
|
+
};
|