@bondedhq/sdk 0.1.7 → 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/README.md +47 -20
- package/dist/agent-url.d.ts +21 -11
- package/dist/agent-url.js +36 -26
- package/dist/signature.d.ts +41 -25
- package/dist/signature.js +43 -36
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -558,19 +558,20 @@ an episode. `mcpConfigJson(episode, options)` returns the same text.
|
|
|
558
558
|
|
|
559
559
|
### Agent URL
|
|
560
560
|
|
|
561
|
-
In Agent URL mode Bonded `POST`s each episode to your HTTPS endpoint, signed with
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
endpoint: a standard Fetch API handler for
|
|
566
|
-
Workers, with a Node adapter for Express
|
|
561
|
+
In Agent URL mode Bonded `POST`s each episode to your HTTPS endpoint, signed with Bonded's own
|
|
562
|
+
key for the wallet that saved the URL. There is no secret: your handler names its `owner`, your
|
|
563
|
+
wallet, in code (it's public), and refuses calls signed for anyone else. Deploy it, save the URL
|
|
564
|
+
on Bonded from that wallet, then verify: Bonded sends a signed challenge and your endpoint answers
|
|
565
|
+
with its owner. `createBondedHandler` is the whole endpoint: a standard Fetch API handler for
|
|
566
|
+
Hono, Next.js route handlers, Bun, Deno and Cloudflare Workers, with a Node adapter for Express
|
|
567
|
+
and `node:http`.
|
|
567
568
|
|
|
568
569
|
```ts
|
|
569
570
|
import { createBondedHandler, toNodeHandler } from "@bondedhq/sdk/agent-url";
|
|
570
571
|
import { bondedAiSdkTools } from "@bondedhq/sdk/ai-sdk";
|
|
571
572
|
|
|
572
573
|
const handler = createBondedHandler({
|
|
573
|
-
|
|
574
|
+
owner: "0xYourWallet", // the wallet you save this URL from on Bonded; public, not a secret
|
|
574
575
|
version: process.env.GIT_SHA, // reported when Bonded verifies the URL; becomes the code hash
|
|
575
576
|
async onEpisode(episode, { signal }) {
|
|
576
577
|
const tools = await bondedAiSdkTools(episode);
|
|
@@ -590,15 +591,21 @@ expressApp.post("/bonded", toNodeHandler(handler)); // Express: mount before exp
|
|
|
590
591
|
|
|
591
592
|
What the handler does with each request:
|
|
592
593
|
|
|
593
|
-
1. **Checks the
|
|
594
|
-
within 300 seconds of the server's clock
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
594
|
+
1. **Checks the call** before reading the message. `bonded-timestamp` (unix seconds) must be
|
|
595
|
+
within 300 seconds of the server's clock; `bonded-owner` must be your `owner` (a list works,
|
|
596
|
+
for a team); and `bonded-signature` (`v2=<key id>:<base64url Ed25519 signature>` over
|
|
597
|
+
`v2.<key id>.<timestamp>.<owner>.<raw body>`, up to four while a key rotates) must verify
|
|
598
|
+
under one of Bonded's public keys. The handler fetches those from
|
|
599
|
+
`https://www.bondedhq.xyz/.well-known/bonded-agent-keys.json` (`apiUrl` takes them from
|
|
600
|
+
another Bonded deployment instead, for a local Arena run), keeps them for a day, and fetches
|
|
601
|
+
again at once (at most once a minute) for a key it hasn't seen. A signature it has accepted before is refused as
|
|
602
|
+
a replay. Failures answer `401` (with `code: "wrong-owner"` when the call is
|
|
603
|
+
for a wallet the handler isn't set up for, so Bonded can tell you exactly that), a body that
|
|
604
|
+
isn't one of Bonded's messages `400`, and keys it can't fetch `503`; nothing runs.
|
|
605
|
+
2. `{ type: "verify", challenge }`: answers `200 { challenge, owner, version }`. Bonded marks the
|
|
606
|
+
URL verified when `owner` is the wallet that saved it. The owner is only in the signed
|
|
607
|
+
headers, never the body, so an echo server can't pass, and someone who saves your URL gets
|
|
608
|
+
calls signed for their own wallet, which your handler refuses.
|
|
602
609
|
3. `{ type: "episode", ... }`: answers `202` at once (Bonded waits at most 10 seconds) and runs
|
|
603
610
|
`onEpisode` in the background. When it resolves, the handler posts `/done` with the
|
|
604
611
|
`finalMessage` and `usage` it returned. If it throws, `/done` is still posted, saying the agent
|
|
@@ -609,10 +616,30 @@ What the handler does with each request:
|
|
|
609
616
|
Limits: 256 KiB bodies (`maxBodyBytes`, the most Bonded sends), 8 episodes at once (`maxConcurrent`; Bonded sends one at a
|
|
610
617
|
time per agent), and the same episode delivered twice runs once. On platforms that stop work when
|
|
611
618
|
the response is sent, pass their `waitUntil` (as the option, or the handler's second argument).
|
|
612
|
-
|
|
613
|
-
`
|
|
614
|
-
|
|
615
|
-
|
|
619
|
+
|
|
620
|
+
To test your endpoint, `createTestSigner()` stands in for Bonded: give the handler its `keys`, and
|
|
621
|
+
`sign(owner, body)` returns the headers Bonded would send.
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
import { createBondedHandler, createTestSigner } from "@bondedhq/sdk/agent-url";
|
|
625
|
+
|
|
626
|
+
const owner = "0x1111111111111111111111111111111111111111"; // your wallet
|
|
627
|
+
const bonded = await createTestSigner();
|
|
628
|
+
const handler = createBondedHandler({ owner, keys: bonded.keys, onEpisode });
|
|
629
|
+
const body = JSON.stringify({ type: "verify", challenge: "a-test-challenge-0001" });
|
|
630
|
+
const res = await handler(
|
|
631
|
+
new Request("http://localhost/bonded", {
|
|
632
|
+
method: "POST",
|
|
633
|
+
headers: { "content-type": "application/json", ...(await bonded.sign(owner, body)) },
|
|
634
|
+
body,
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
// 200 { challenge: "a-test-challenge-0001", owner: "0x1111…1111" }
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
`verifyAgentUrlRequest(...)` is the check on its own and `answerAgentUrlVerify(...)` the verify
|
|
641
|
+
answer, from `@bondedhq/shared/agent-url` (a small entry point without the contract ABIs, for
|
|
642
|
+
edge bundles). A Python endpoint (the standard library and `cryptography`) is in
|
|
616
643
|
[`examples/python-agent-url`](../../examples/python-agent-url/README.md).
|
|
617
644
|
|
|
618
645
|
## Vault trading tools for LangChain, LangGraph and the AI SDK
|
package/dist/agent-url.d.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type AgentUrlPublicKey } from "@bondedhq/shared/agent-url";
|
|
2
3
|
import type { BondedBrokerageEpisode, BondedEpisode } from "./episode.js";
|
|
3
4
|
import type { EpisodeUsage } from "./mcp.js";
|
|
4
|
-
/** For your own handler: the
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
5
|
+
/** For your own handler: the verify answer, and the request check. */
|
|
6
|
+
export { AGENT_URL_KEYS_PATH, AgentUrlKeys, type AgentUrlPublicKey, answerAgentUrlVerify, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
|
|
7
|
+
export { BONDED_AGENT_KEYS, BONDED_AGENT_KEYS_URL, createTestSigner, keysUrlOf, OWNER_HEADER, SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, TIMESTAMP_HEADER, } from "./signature.js";
|
|
7
8
|
/**
|
|
8
9
|
* Agent URL mode: instead of running the Bonded runner, an agent serves one HTTPS endpoint and
|
|
9
10
|
* Bonded calls it once per episode. Every call is a signed `POST` of one JSON message:
|
|
10
11
|
*
|
|
11
|
-
* - `{ type: "verify", challenge }` when the URL is
|
|
12
|
-
* `200 { challenge,
|
|
13
|
-
* `"verify." + challenge` under the secret.
|
|
12
|
+
* - `{ type: "verify", challenge }` when the URL is saved: answer
|
|
13
|
+
* `200 { challenge, owner, version? }`, where `owner` is your wallet.
|
|
14
14
|
* - `{ type: "episode", ... }` (the runner protocol's `EpisodeMessage`): answer `2xx` within
|
|
15
15
|
* 10 seconds, then work: call the episode's tools and `POST {done}` when finished.
|
|
16
16
|
* - `{ type: "cancel", episodeId }`: stop that episode (best effort).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
18
|
+
* Every call is signed by Bonded's own key and names the owner it is for; there is no secret to
|
|
19
|
+
* set. `createBondedHandler` does all of that: it checks signatures against Bonded's published
|
|
20
|
+
* keys, refuses calls for any wallet but `owner`, answers challenges, acknowledges an
|
|
19
21
|
* episode at once and runs your `onEpisode` in the background, aborts it on cancel or at the
|
|
20
22
|
* deadline, and posts `/done` (with the usage you return) when it resolves.
|
|
21
23
|
*/
|
|
@@ -34,10 +36,18 @@ export interface EpisodeContext {
|
|
|
34
36
|
}
|
|
35
37
|
export interface BondedHandlerOptions {
|
|
36
38
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
+
* Your wallet: the one you sign in to Bonded with and save this URL from (or several, for a
|
|
40
|
+
* team). Bonded signs each call for the wallet that saved the URL, and the handler refuses calls
|
|
41
|
+
* for anyone else, so nobody can claim your URL as theirs. It's public, not a secret.
|
|
39
42
|
*/
|
|
40
|
-
|
|
43
|
+
owner: string | string[];
|
|
44
|
+
/**
|
|
45
|
+
* Another Bonded deployment to take the signing keys from (a local Arena, a staging API). Leave
|
|
46
|
+
* it out for Bonded itself: the keys then come from BONDED_AGENT_KEYS_URL.
|
|
47
|
+
*/
|
|
48
|
+
apiUrl?: string;
|
|
49
|
+
/** Bonded's public keys, given directly instead of fetched (for tests: `createTestSigner`). */
|
|
50
|
+
keys?: AgentUrlPublicKey[];
|
|
41
51
|
/** Runs one episode. Resolve when the agent is finished; the handler then posts `/done`. */
|
|
42
52
|
onEpisode(episode: BondedEpisode, context: EpisodeContext): Promise<EpisodeOutcome | void> | EpisodeOutcome | void;
|
|
43
53
|
/**
|
|
@@ -80,7 +90,7 @@ export type BondedHandler = (request: Request, context?: {
|
|
|
80
90
|
*
|
|
81
91
|
* ```ts
|
|
82
92
|
* const handler = createBondedHandler({
|
|
83
|
-
*
|
|
93
|
+
* owner: "0xYourWallet",
|
|
84
94
|
* version: process.env.GIT_SHA,
|
|
85
95
|
* async onEpisode(episode, { signal }) {
|
|
86
96
|
* const tools = await bondedAiSdkTools(episode);
|
package/dist/agent-url.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { AGENT_URL_MAX_BODY_BYTES, AGENT_URL_TOLERANCE_SECONDS, AgentUrlReplayGuard, answerAgentUrlVerify, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
|
|
1
|
+
import { AGENT_URL_MAX_BODY_BYTES, AGENT_URL_TOLERANCE_SECONDS, AgentUrlKeys, AgentUrlReplayGuard, agentUrlSignatureKeyIds, answerAgentUrlVerify, isAgentUrlOwner, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
|
|
2
|
+
import { BONDED_AGENT_KEYS, BONDED_AGENT_KEYS_URL, keysUrlOf } from "./signature.js";
|
|
2
3
|
import { finishEpisode } from "./done.js";
|
|
3
|
-
/** For your own handler: the
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
4
|
+
/** For your own handler: the verify answer, and the request check. */
|
|
5
|
+
export { AGENT_URL_KEYS_PATH, AgentUrlKeys, answerAgentUrlVerify, verifyAgentUrlRequest, } from "@bondedhq/shared/agent-url";
|
|
6
|
+
export { BONDED_AGENT_KEYS, BONDED_AGENT_KEYS_URL, createTestSigner, keysUrlOf, OWNER_HEADER, SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, TIMESTAMP_HEADER, } from "./signature.js";
|
|
6
7
|
/**
|
|
7
8
|
* An Agent URL endpoint for your agent.
|
|
8
9
|
*
|
|
9
10
|
* ```ts
|
|
10
11
|
* const handler = createBondedHandler({
|
|
11
|
-
*
|
|
12
|
+
* owner: "0xYourWallet",
|
|
12
13
|
* version: process.env.GIT_SHA,
|
|
13
14
|
* async onEpisode(episode, { signal }) {
|
|
14
15
|
* const tools = await bondedAiSdkTools(episode);
|
|
@@ -20,15 +21,23 @@ export { SIGNATURE_HEADER, SIGNATURE_TOLERANCE_SECONDS, signBondedRequest, TIMES
|
|
|
20
21
|
* ```
|
|
21
22
|
*/
|
|
22
23
|
export function createBondedHandler(options) {
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
const owners = Array.isArray(options.owner) ? options.owner : [options.owner];
|
|
25
|
+
if (owners.length === 0 || !owners.every((o) => typeof o === "string" && isAgentUrlOwner(o)))
|
|
26
|
+
throw new Error("createBondedHandler needs owner: your wallet address (0x…), the one you save this URL from");
|
|
27
|
+
const keyStore = options.keys
|
|
28
|
+
? undefined
|
|
29
|
+
: new AgentUrlKeys(options.apiUrl ? keysUrlOf(options.apiUrl) : BONDED_AGENT_KEYS_URL, {
|
|
30
|
+
// The built-in keys are Bonded's own: never a fallback for another deployment's.
|
|
31
|
+
...(options.apiUrl ? {} : { fallback: BONDED_AGENT_KEYS }),
|
|
32
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
33
|
+
...(options.now ? { now: options.now } : {}),
|
|
34
|
+
});
|
|
25
35
|
const now = options.now ?? Date.now;
|
|
26
36
|
const tolerance = options.toleranceSeconds ?? AGENT_URL_TOLERANCE_SECONDS;
|
|
27
37
|
const maxBody = options.maxBodyBytes ?? AGENT_URL_MAX_BODY_BYTES;
|
|
28
38
|
const maxConcurrent = options.maxConcurrent ?? 8;
|
|
29
39
|
const onError = options.onError ??
|
|
30
40
|
((error, episode) => console.error(`Bonded episode ${episode.episodeId} failed:`, error));
|
|
31
|
-
const secrets = Array.isArray(options.secret) ? options.secret : [options.secret];
|
|
32
41
|
/** Signatures already accepted, until they fall out of the window: replays are refused. */
|
|
33
42
|
const replay = new AgentUrlReplayGuard(tolerance);
|
|
34
43
|
const running = new Map();
|
|
@@ -85,8 +94,19 @@ export function createBondedHandler(options) {
|
|
|
85
94
|
const body = await readCapped(request, maxBody);
|
|
86
95
|
if (body === undefined)
|
|
87
96
|
return json(413, { error: `the body is over ${maxBody} bytes` });
|
|
97
|
+
let keys;
|
|
98
|
+
try {
|
|
99
|
+
keys =
|
|
100
|
+
options.keys ??
|
|
101
|
+
(await keyStore.get(agentUrlSignatureKeyIds(request.headers).find(Boolean)));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error("Bonded: couldn't fetch Bonded's signing keys:", error);
|
|
105
|
+
return json(503, { error: "the agent couldn't fetch Bonded's signing keys; try again" });
|
|
106
|
+
}
|
|
88
107
|
const check = await verifyAgentUrlRequest({
|
|
89
|
-
|
|
108
|
+
owner: owners,
|
|
109
|
+
keys,
|
|
90
110
|
body,
|
|
91
111
|
headers: request.headers,
|
|
92
112
|
nowMs: now(),
|
|
@@ -94,11 +114,15 @@ export function createBondedHandler(options) {
|
|
|
94
114
|
replay,
|
|
95
115
|
});
|
|
96
116
|
if (!check.ok)
|
|
97
|
-
return json(check.status, {
|
|
117
|
+
return json(check.status, {
|
|
118
|
+
error: check.reason,
|
|
119
|
+
...(check.code ? { code: check.code } : {}),
|
|
120
|
+
});
|
|
98
121
|
const message = check.message;
|
|
99
|
-
// The answer
|
|
122
|
+
// The answer names the owner the call was signed for (one of ours, checked above). The verify
|
|
123
|
+
// body carries no owner, so a server that echoes it back can't pass.
|
|
100
124
|
if (message.type === "verify")
|
|
101
|
-
return json(200,
|
|
125
|
+
return json(200, answerAgentUrlVerify(check.owner, message, options.version));
|
|
102
126
|
if (message.type === "cancel") {
|
|
103
127
|
const controller = running.get(message.episodeId);
|
|
104
128
|
controller?.abort(new Error("Bonded cancelled the episode"));
|
|
@@ -125,20 +149,6 @@ export function createBondedHandler(options) {
|
|
|
125
149
|
return json(202, { ok: true, episodeId: message.episodeId });
|
|
126
150
|
};
|
|
127
151
|
}
|
|
128
|
-
/**
|
|
129
|
-
* Which of the secrets signed a request already verified (while a secret rotates, the verify
|
|
130
|
-
* proof must be made with the one Bonded holds).
|
|
131
|
-
*/
|
|
132
|
-
async function signingSecret(secrets, body, headers, nowMs, toleranceSeconds) {
|
|
133
|
-
if (secrets.length === 1)
|
|
134
|
-
return secrets[0];
|
|
135
|
-
for (const secret of secrets) {
|
|
136
|
-
const check = await verifyAgentUrlRequest({ secret, body, headers, nowMs, toleranceSeconds });
|
|
137
|
-
if (check.ok)
|
|
138
|
-
return secret;
|
|
139
|
-
}
|
|
140
|
-
return secrets[0];
|
|
141
|
-
}
|
|
142
152
|
/** A brokerage episode as the agent sees it (the same shape `fromBondedBrokerageEnv` returns). */
|
|
143
153
|
function toBrokerageEpisode(message) {
|
|
144
154
|
return {
|
package/dist/signature.d.ts
CHANGED
|
@@ -1,34 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent URL request signatures: how an agent knows a request really comes from Bonded, for its
|
|
3
|
+
* owner.
|
|
4
|
+
*
|
|
5
|
+
* Bonded signs every call to an Agent URL with its own Ed25519 key (no shared secret):
|
|
6
|
+
* - `bonded-timestamp`: unix seconds;
|
|
7
|
+
* - `bonded-owner`: the wallet that saved the URL on Bonded;
|
|
8
|
+
* - `bonded-signature`: `v2=<key id>:<base64url signature>` over
|
|
9
|
+
* `v2.<key id>.<timestamp>.<owner>.<raw body>`.
|
|
10
|
+
*
|
|
11
|
+
* The handler checks it against Bonded's published keys (BONDED_AGENT_KEYS_URL) and refuses calls
|
|
12
|
+
* for any owner but its own. The scheme lives in @bondedhq/shared
|
|
13
|
+
* (`@bondedhq/shared/agent-url`, which the gateway signs with). Web Crypto only, so it runs in
|
|
14
|
+
* Node 22+, Bun, Deno, Cloudflare Workers and edge runtimes.
|
|
15
|
+
*/
|
|
16
|
+
import { type AgentUrlPublicKey } from "@bondedhq/shared/agent-url";
|
|
1
17
|
export declare const SIGNATURE_HEADER = "bonded-signature";
|
|
2
18
|
export declare const TIMESTAMP_HEADER = "bonded-timestamp";
|
|
19
|
+
export declare const OWNER_HEADER = "bonded-owner";
|
|
3
20
|
/** How far a request's timestamp may be from the agent's clock, either way. */
|
|
4
21
|
export declare const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
5
22
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
23
|
+
* Where Bonded publishes the keys that sign calls to agents. It is on Bonded's own domain, not the
|
|
24
|
+
* API's host name, because this address is built into every deployed handler: the API can move
|
|
25
|
+
* without breaking them.
|
|
8
26
|
*/
|
|
9
|
-
export declare
|
|
10
|
-
"bonded-timestamp": string;
|
|
11
|
-
"bonded-signature": string;
|
|
12
|
-
}>;
|
|
13
|
-
export type SignatureCheck = {
|
|
14
|
-
ok: true;
|
|
15
|
-
timestamp: number;
|
|
16
|
-
} | {
|
|
17
|
-
ok: false;
|
|
18
|
-
reason: string;
|
|
19
|
-
};
|
|
27
|
+
export declare const BONDED_AGENT_KEYS_URL = "https://www.bondedhq.xyz/.well-known/bonded-agent-keys.json";
|
|
20
28
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
29
|
+
* Bonded's production keys, built in: used only when BONDED_AGENT_KEYS_URL can't be reached and
|
|
30
|
+
* no key set was fetched before, so a handler still starts then. The same set the keys address
|
|
31
|
+
* publishes (docs/AGENT-URL-SIGNED-CALLS.md, Section 6): on a key rotation, add the new public key
|
|
32
|
+
* here before a release.
|
|
24
33
|
*/
|
|
25
|
-
export declare
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
export declare const BONDED_AGENT_KEYS: AgentUrlPublicKey[];
|
|
35
|
+
/** Where another Bonded deployment (a local Arena, a staging API) publishes its keys. */
|
|
36
|
+
export declare const keysUrlOf: (apiUrl: string) => string;
|
|
37
|
+
/**
|
|
38
|
+
* A stand-in for Bonded, for testing your endpoint locally: a fresh key, `sign` to sign a body
|
|
39
|
+
* for an owner the way Bonded does, and `keys` to give the handler (`createBondedHandler({ keys })`).
|
|
40
|
+
* Bonded signs real calls itself, with keys your handler fetches.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createTestSigner(): Promise<{
|
|
43
|
+
keys: AgentUrlPublicKey[];
|
|
44
|
+
sign(owner: string, body: string, timestamp?: number): Promise<{
|
|
45
|
+
"bonded-timestamp": string;
|
|
46
|
+
"bonded-owner": string;
|
|
47
|
+
"bonded-signature": string;
|
|
48
|
+
}>;
|
|
49
|
+
}>;
|
|
34
50
|
//# sourceMappingURL=signature.d.ts.map
|
package/dist/signature.js
CHANGED
|
@@ -1,52 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent URL request signatures: how an agent knows a request really comes from Bonded
|
|
2
|
+
* Agent URL request signatures: how an agent knows a request really comes from Bonded, for its
|
|
3
|
+
* owner.
|
|
3
4
|
*
|
|
4
|
-
* Bonded signs every call to an Agent URL with
|
|
5
|
+
* Bonded signs every call to an Agent URL with its own Ed25519 key (no shared secret):
|
|
5
6
|
* - `bonded-timestamp`: unix seconds;
|
|
6
|
-
* - `bonded-
|
|
7
|
-
*
|
|
7
|
+
* - `bonded-owner`: the wallet that saved the URL on Bonded;
|
|
8
|
+
* - `bonded-signature`: `v2=<key id>:<base64url signature>` over
|
|
9
|
+
* `v2.<key id>.<timestamp>.<owner>.<raw body>`.
|
|
8
10
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* The handler checks it against Bonded's published keys (BONDED_AGENT_KEYS_URL) and refuses calls
|
|
12
|
+
* for any owner but its own. The scheme lives in @bondedhq/shared
|
|
13
|
+
* (`@bondedhq/shared/agent-url`, which the gateway signs with). Web Crypto only, so it runs in
|
|
14
|
+
* Node 22+, Bun, Deno, Cloudflare Workers and edge runtimes.
|
|
12
15
|
*/
|
|
13
|
-
import { AGENT_URL_SIGNATURE_HEADER, AGENT_URL_TIMESTAMP_HEADER, AGENT_URL_TOLERANCE_SECONDS,
|
|
16
|
+
import { AGENT_URL_KEYS_PATH, AGENT_URL_OWNER_HEADER, AGENT_URL_SIGNATURE_HEADER, AGENT_URL_TIMESTAMP_HEADER, AGENT_URL_TOLERANCE_SECONDS, agentUrlSigner, newAgentUrlSigningKey, } from "@bondedhq/shared/agent-url";
|
|
14
17
|
export const SIGNATURE_HEADER = AGENT_URL_SIGNATURE_HEADER;
|
|
15
18
|
export const TIMESTAMP_HEADER = AGENT_URL_TIMESTAMP_HEADER;
|
|
19
|
+
export const OWNER_HEADER = AGENT_URL_OWNER_HEADER;
|
|
16
20
|
/** How far a request's timestamp may be from the agent's clock, either way. */
|
|
17
21
|
export const SIGNATURE_TOLERANCE_SECONDS = AGENT_URL_TOLERANCE_SECONDS;
|
|
18
22
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* Where Bonded publishes the keys that sign calls to agents. It is on Bonded's own domain, not the
|
|
24
|
+
* API's host name, because this address is built into every deployed handler: the API can move
|
|
25
|
+
* without breaking them.
|
|
21
26
|
*/
|
|
22
|
-
export
|
|
23
|
-
const headers = await signAgentUrlRequest(secret, body, timestamp * 1000);
|
|
24
|
-
return {
|
|
25
|
-
"bonded-timestamp": headers[TIMESTAMP_HEADER],
|
|
26
|
-
"bonded-signature": headers[SIGNATURE_HEADER],
|
|
27
|
-
};
|
|
28
|
-
}
|
|
27
|
+
export const BONDED_AGENT_KEYS_URL = `https://www.bondedhq.xyz${AGENT_URL_KEYS_PATH}`;
|
|
29
28
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* Bonded's production keys, built in: used only when BONDED_AGENT_KEYS_URL can't be reached and
|
|
30
|
+
* no key set was fetched before, so a handler still starts then. The same set the keys address
|
|
31
|
+
* publishes (docs/AGENT-URL-SIGNED-CALLS.md, Section 6): on a key rotation, add the new public key
|
|
32
|
+
* here before a release.
|
|
33
33
|
*/
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
export const BONDED_AGENT_KEYS = [
|
|
35
|
+
// The first production key, made 2026-11-14.
|
|
36
|
+
{ kid: "ind7eq0xdV0-", alg: "Ed25519", x: "tWhxWPMrq6BKcQs838cUlF_5SecIG1NBy8L_IRIkSBg" },
|
|
37
|
+
];
|
|
38
|
+
/** Where another Bonded deployment (a local Arena, a staging API) publishes its keys. */
|
|
39
|
+
export const keysUrlOf = (apiUrl) => `${apiUrl.replace(/\/+$/, "")}${AGENT_URL_KEYS_PATH}`;
|
|
40
|
+
/**
|
|
41
|
+
* A stand-in for Bonded, for testing your endpoint locally: a fresh key, `sign` to sign a body
|
|
42
|
+
* for an owner the way Bonded does, and `keys` to give the handler (`createBondedHandler({ keys })`).
|
|
43
|
+
* Bonded signs real calls itself, with keys your handler fetches.
|
|
44
|
+
*/
|
|
45
|
+
export async function createTestSigner() {
|
|
46
|
+
const signer = await agentUrlSigner(await newAgentUrlSigningKey());
|
|
47
|
+
return {
|
|
48
|
+
keys: signer.keySet().keys,
|
|
49
|
+
async sign(owner, body, timestamp = Math.floor(Date.now() / 1000)) {
|
|
50
|
+
const headers = await signer.sign(owner, body, timestamp * 1000);
|
|
51
|
+
return {
|
|
52
|
+
"bonded-timestamp": headers[TIMESTAMP_HEADER],
|
|
53
|
+
"bonded-owner": headers[OWNER_HEADER],
|
|
54
|
+
"bonded-signature": headers[SIGNATURE_HEADER],
|
|
55
|
+
};
|
|
41
56
|
},
|
|
42
|
-
|
|
43
|
-
...(input.toleranceSeconds !== undefined ? { toleranceSeconds: input.toleranceSeconds } : {}),
|
|
44
|
-
});
|
|
45
|
-
if (result.ok)
|
|
46
|
-
return { ok: true, timestamp: result.timestamp };
|
|
47
|
-
// 400 means the signature matched but the body isn't one of Bonded's messages.
|
|
48
|
-
if (result.status === 400)
|
|
49
|
-
return { ok: true, timestamp: Number(input.timestamp) };
|
|
50
|
-
return { ok: false, reason: result.reason };
|
|
57
|
+
};
|
|
51
58
|
}
|
|
52
59
|
//# sourceMappingURL=signature.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bondedhq/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Trade through a Bonded Mandate Vault and get rated by the Bonded Arena from any TypeScript agent: a typed vault client with dry runs, Sentinel checks and a mandate builder; the Arena episode's tools for LangChain, the Vercel AI SDK, ElizaOS and Coinbase AgentKit; a payment guard for pay tools (LangChain, the AI SDK, MCP, the OpenAI Agents SDK) and x402 clients; and an Agent URL handler.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"@modelcontextprotocol/sdk": "1.30.1",
|
|
95
95
|
"viem": "^2.56.9",
|
|
96
96
|
"zod": "^4.6.5",
|
|
97
|
-
"@bondedhq/shared": "^0.
|
|
97
|
+
"@bondedhq/shared": "^0.2.0"
|
|
98
98
|
},
|
|
99
99
|
"devDependencies": {
|
|
100
100
|
"@coinbase/agentkit": "0.10.4",
|