@ziggs-ai/api-client 0.14.3 → 0.15.1
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/dist/capabilities/agreementVerbs.js +19 -2
- package/dist/capabilities/agreements.js +1 -1
- package/dist/capabilities/connections.d.ts +1 -1
- package/dist/capabilities/connections.js +82 -59
- package/dist/capabilities/links.js +9 -1
- package/dist/http/AgreementClient.d.ts +29 -6
- package/dist/http/AgreementClient.js +2 -0
- package/dist/http/ConnectionsClient.d.ts +13 -4
- package/dist/http/ConnectionsClient.js +50 -38
- package/dist/http/MarketplaceClient.d.ts +7 -0
- package/dist/http/MarketplaceClient.js +12 -3
- package/dist/http/PaymentsClient.d.ts +22 -4
- package/dist/http/PaymentsClient.js +48 -19
- package/dist/http/agreementFlows.js +9 -3
- package/dist/http/paymentGrantSelection.d.ts +53 -0
- package/dist/http/paymentGrantSelection.js +111 -0
- package/package.json +1 -1
|
@@ -122,10 +122,27 @@ const COUNTERPARTY_PARAM = {
|
|
|
122
122
|
required: true,
|
|
123
123
|
description: 'Agent or user id of the counterparty.',
|
|
124
124
|
};
|
|
125
|
+
/**
|
|
126
|
+
* Optional, because the server has always accepted a proposal without one and
|
|
127
|
+
* the client was the only thing refusing.
|
|
128
|
+
*
|
|
129
|
+
* The room is where a conversation about the terms happens; it is not how the
|
|
130
|
+
* counterparty is TOLD. A directed proposal is delivered to the party slots
|
|
131
|
+
* (the create-time agreement event targets parties, not a chat), so a chatless
|
|
132
|
+
* proposal lands in their inbox exactly like any other. What is lost is
|
|
133
|
+
* narrower than it sounds: the notice posted INTO a room, which is worth
|
|
134
|
+
* nothing when there is no room.
|
|
135
|
+
*
|
|
136
|
+
* Requiring it here meant a bookkeeping agreement between parties who have no
|
|
137
|
+
* conversation had to invent a chat id to exist. The lab's own self-hire did:
|
|
138
|
+
* it passed `lab-hire-<run>`, a chat that does not exist, which the server
|
|
139
|
+
* tolerates by falling back to the credential's tenant. A fiction the caller
|
|
140
|
+
* was forced to write down is worse than an absent field.
|
|
141
|
+
*/
|
|
125
142
|
const CHAT_PARAM = {
|
|
126
143
|
type: 'string',
|
|
127
|
-
required:
|
|
128
|
-
description: 'The room this is proposed in — the counterparty
|
|
144
|
+
required: false,
|
|
145
|
+
description: 'The room this is proposed in, when there is one — the counterparty can read and discuss the terms there. Omit it for an agreement between parties with no conversation; the proposal still reaches them, it just has no room to be discussed in.',
|
|
129
146
|
};
|
|
130
147
|
/** Marketplace follow-up: a published listing is claimed, never countered. */
|
|
131
148
|
function publishedNext(env) {
|
|
@@ -25,7 +25,7 @@ export function presentClaimResult(agreement, kind, env) {
|
|
|
25
25
|
}
|
|
26
26
|
if (agreement?.status !== 'active') {
|
|
27
27
|
const held = (agreement?.approvals ?? []).find((a) => a.status === 'pending');
|
|
28
|
-
const approveUrl = `${webAppOrigin(env)}/app/
|
|
28
|
+
const approveUrl = `${webAppOrigin(env)}/app/agreements/${agreement.agreementId}`;
|
|
29
29
|
const remedy = held?.heldReason === 'contact-basis'
|
|
30
30
|
? 'This is a first engagement with that counterparty — your human approves once; a standing link covers it after that.'
|
|
31
31
|
: 'Claiming for a job your human already approved activates at once — name the job with mandateAgreementId.';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CapabilityDefinition } from
|
|
1
|
+
import { type CapabilityDefinition } from "./types.js";
|
|
2
2
|
export declare const connectionProxyCapability: CapabilityDefinition;
|
|
3
3
|
export declare const requestConnectionCapability: CapabilityDefinition;
|
|
4
4
|
export declare const CONNECTION_CAPABILITIES: CapabilityDefinition[];
|
|
@@ -1,115 +1,138 @@
|
|
|
1
|
-
import { ConnectionsClient } from
|
|
2
|
-
import { rethrowWithContext, } from
|
|
1
|
+
import { ConnectionsClient } from "../http/ConnectionsClient.js";
|
|
2
|
+
import { rethrowWithContext, } from "./types.js";
|
|
3
3
|
function client(env) {
|
|
4
|
-
|
|
4
|
+
// laneId, not just the key and the agent. The lane is which engagement this
|
|
5
|
+
// wake is in, and the broker needs it to tell one hirer's grant from
|
|
6
|
+
// another's: an agent that serves several hirers is the holder of every grant
|
|
7
|
+
// it has been given, so the holder check alone cannot separate them. Every
|
|
8
|
+
// other Creds-based client already carries it.
|
|
9
|
+
const { operatorKey, agentId, laneId } = env.creds;
|
|
5
10
|
if (!operatorKey)
|
|
6
|
-
throw new Error(
|
|
7
|
-
return new ConnectionsClient(operatorKey, agentId, env.baseUrl);
|
|
11
|
+
throw new Error("operatorKey missing from tool context");
|
|
12
|
+
return new ConnectionsClient(operatorKey, agentId, env.baseUrl, laneId);
|
|
8
13
|
}
|
|
9
14
|
export const connectionProxyCapability = {
|
|
10
|
-
key:
|
|
11
|
-
names: { sdk:
|
|
12
|
-
title:
|
|
15
|
+
key: "connection_proxy",
|
|
16
|
+
names: { sdk: "connection_proxy", mcp: "ziggs_connection_proxy" },
|
|
17
|
+
title: "Use a stored connection",
|
|
13
18
|
descriptions: {
|
|
14
19
|
sdk: "Use a named-connector stored connection (e.g. the owner's GitHub/Jira) without ever seeing the credential. " +
|
|
15
|
-
|
|
20
|
+
"Calls the backend connections proxy with a grant the owner issued to this agent. " +
|
|
16
21
|
'Not for remote MCP servers (provider "mcp") — those use mcp_tool_call / mcp_tools_list; proxy refuses them with "Unknown provider: mcp". ' +
|
|
17
22
|
"Don't know connectionId/grantId yet? Use grant_list (scopeKind=connection) or connection_list_grants first.",
|
|
18
23
|
mcp: "Use a named-connector stored connection (e.g. the owner's GitHub/Jira — NOT an agent-to-agent Link, see ziggs_link_list) without ever seeing the credential. " +
|
|
19
|
-
|
|
24
|
+
"Calls the backend connections proxy with a grant the owner issued to this agent. " +
|
|
20
25
|
'Not for remote MCP servers (provider "mcp") — those use ziggs_mcp_tool_call / ziggs_mcp_tools_list; proxy refuses them with "Unknown provider: mcp". ' +
|
|
21
26
|
"Don't know connectionId/grantId yet? Call ziggs_connection_list first.",
|
|
22
27
|
},
|
|
23
|
-
annotation:
|
|
28
|
+
annotation: "write",
|
|
24
29
|
params: {
|
|
25
|
-
connectionId: {
|
|
30
|
+
connectionId: {
|
|
31
|
+
type: "string",
|
|
32
|
+
required: true,
|
|
33
|
+
description: "Connection to act on",
|
|
34
|
+
},
|
|
26
35
|
grantId: {
|
|
27
|
-
type:
|
|
36
|
+
type: "string",
|
|
37
|
+
required: true,
|
|
38
|
+
description: "Grant the owner issued to this agent for the connection",
|
|
39
|
+
},
|
|
40
|
+
action: {
|
|
41
|
+
type: "string",
|
|
28
42
|
required: true,
|
|
29
|
-
description:
|
|
43
|
+
description: "Provider action, e.g. repo:read",
|
|
44
|
+
},
|
|
45
|
+
payload: {
|
|
46
|
+
type: "object",
|
|
47
|
+
description: "Action-specific arguments (provider-defined)",
|
|
30
48
|
},
|
|
31
|
-
action: { type: 'string', required: true, description: 'Provider action, e.g. repo:read' },
|
|
32
|
-
payload: { type: 'object', description: 'Action-specific arguments (provider-defined)' },
|
|
33
49
|
},
|
|
34
50
|
needsAgentId: true,
|
|
35
51
|
handler: async (args, env) => {
|
|
36
|
-
if (!args[
|
|
37
|
-
throw new Error(
|
|
38
|
-
if (!args[
|
|
39
|
-
throw new Error(
|
|
40
|
-
if (!args[
|
|
41
|
-
throw new Error(
|
|
52
|
+
if (!args["connectionId"])
|
|
53
|
+
throw new Error("connectionId is required");
|
|
54
|
+
if (!args["grantId"])
|
|
55
|
+
throw new Error("grantId is required");
|
|
56
|
+
if (!args["action"])
|
|
57
|
+
throw new Error("action is required");
|
|
42
58
|
try {
|
|
43
59
|
const result = await client(env).proxy({
|
|
44
|
-
connectionId: args[
|
|
45
|
-
grantId: args[
|
|
46
|
-
action: args[
|
|
47
|
-
payload: args[
|
|
60
|
+
connectionId: args["connectionId"],
|
|
61
|
+
grantId: args["grantId"],
|
|
62
|
+
action: args["action"],
|
|
63
|
+
payload: args["payload"],
|
|
48
64
|
});
|
|
49
|
-
return { ok: true, action: args[
|
|
65
|
+
return { ok: true, action: args["action"], result };
|
|
50
66
|
}
|
|
51
67
|
catch (e) {
|
|
52
|
-
rethrowWithContext(e,
|
|
68
|
+
rethrowWithContext(e, "Connection proxy failed");
|
|
53
69
|
}
|
|
54
70
|
},
|
|
55
71
|
};
|
|
56
72
|
export const requestConnectionCapability = {
|
|
57
|
-
key:
|
|
58
|
-
names: { sdk:
|
|
59
|
-
title:
|
|
73
|
+
key: "connection_request",
|
|
74
|
+
names: { sdk: "connection_request", mcp: "ziggs_connection_request" },
|
|
75
|
+
title: "Ask your principal for a connection",
|
|
60
76
|
descriptions: {
|
|
61
|
-
sdk:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
mcp:
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
sdk: "Ask your principal (the human) to connect a remote MCP server and grant you the listed tools. " +
|
|
78
|
+
"Opens a connection-consent agreement as an approvable card in the chat you pass — the human approves it there like any other agreement. " +
|
|
79
|
+
"On approval the server is connected (browser OAuth if needed) and you are granted the tools; call them with mcp_tool_call / mcp_tools_list (not connection_proxy).",
|
|
80
|
+
mcp: "Ask your principal (the human) to connect a remote MCP server and grant you the listed tools. " +
|
|
81
|
+
"Opens a connection-consent agreement as an approvable card in the chat you pass — the human approves it there like any other agreement (there is no MCP tool to approve it, so tell them to approve it in the chat). " +
|
|
82
|
+
"On approval the server is connected (browser OAuth if needed) and you are granted the tools; the result shows up in ziggs_connection_list for use with ziggs_mcp_tools_list / ziggs_mcp_tool_call.",
|
|
67
83
|
},
|
|
68
|
-
annotation:
|
|
84
|
+
annotation: "write",
|
|
69
85
|
params: {
|
|
70
86
|
chatId: {
|
|
71
|
-
type:
|
|
87
|
+
type: "string",
|
|
88
|
+
required: true,
|
|
89
|
+
description: "The chat you are working in — the consent card is opened there",
|
|
90
|
+
},
|
|
91
|
+
serverUrl: {
|
|
92
|
+
type: "string",
|
|
72
93
|
required: true,
|
|
73
|
-
description:
|
|
94
|
+
description: "Remote MCP server URL (https)",
|
|
74
95
|
},
|
|
75
|
-
serverUrl: { type: 'string', required: true, description: 'Remote MCP server URL (https)' },
|
|
76
96
|
tools: {
|
|
77
|
-
type:
|
|
78
|
-
items: { type:
|
|
97
|
+
type: "array",
|
|
98
|
+
items: { type: "string" },
|
|
79
99
|
required: true,
|
|
80
100
|
description: "Tool names you want — become the grant's allowed_actions caveats",
|
|
81
101
|
},
|
|
82
|
-
reason: {
|
|
102
|
+
reason: {
|
|
103
|
+
type: "string",
|
|
104
|
+
description: "Plain-language reason shown to the human deciding",
|
|
105
|
+
},
|
|
83
106
|
},
|
|
84
107
|
needsAgentId: true,
|
|
85
108
|
handler: async (args, env) => {
|
|
86
|
-
if (!args[
|
|
87
|
-
throw new Error(
|
|
88
|
-
if (!args[
|
|
89
|
-
throw new Error(
|
|
90
|
-
const tools = args[
|
|
109
|
+
if (!args["chatId"])
|
|
110
|
+
throw new Error("chatId is required");
|
|
111
|
+
if (!args["serverUrl"])
|
|
112
|
+
throw new Error("serverUrl is required");
|
|
113
|
+
const tools = args["tools"];
|
|
91
114
|
if (!Array.isArray(tools) || tools.length === 0) {
|
|
92
|
-
throw new Error(
|
|
115
|
+
throw new Error("tools must be a non-empty array of tool names");
|
|
93
116
|
}
|
|
94
117
|
try {
|
|
95
118
|
const result = await client(env).requestMcpConnection({
|
|
96
|
-
chatId: args[
|
|
97
|
-
serverUrl: args[
|
|
119
|
+
chatId: args["chatId"],
|
|
120
|
+
serverUrl: args["serverUrl"],
|
|
98
121
|
tools: tools,
|
|
99
|
-
reason: args[
|
|
122
|
+
reason: args["reason"],
|
|
100
123
|
});
|
|
101
124
|
return {
|
|
102
125
|
ok: true,
|
|
103
126
|
...result,
|
|
104
|
-
note: env.surface ===
|
|
105
|
-
?
|
|
106
|
-
|
|
107
|
-
:
|
|
108
|
-
|
|
127
|
+
note: env.surface === "mcp"
|
|
128
|
+
? "A connection-consent card is now in the chat awaiting your principal. Tell the human now (pull-only MCP has no push) — they approve it right in the chat. " +
|
|
129
|
+
"Once approved, the connection + grant appear in ziggs_connection_list for ziggs_mcp_tools_list / ziggs_mcp_tool_call."
|
|
130
|
+
: "A connection-consent card is now in the chat awaiting your principal — they approve it right there. " +
|
|
131
|
+
"Once approved, the connection + grant appear in grant_list (scopeKind=connection) / connection_list_grants for mcp_tools_list / mcp_tool_call.",
|
|
109
132
|
};
|
|
110
133
|
}
|
|
111
134
|
catch (e) {
|
|
112
|
-
rethrowWithContext(e,
|
|
135
|
+
rethrowWithContext(e, "Connection request failed");
|
|
113
136
|
}
|
|
114
137
|
},
|
|
115
138
|
};
|
|
@@ -159,7 +159,7 @@ export const proposeLinkCapability = {
|
|
|
159
159
|
handler: async (args, env) => {
|
|
160
160
|
const to = args['to']?.trim();
|
|
161
161
|
const maxClaims = args['maxClaims'];
|
|
162
|
-
const { shareUrl } = await createLink({
|
|
162
|
+
const { shareUrl, agreementId } = await createLink({
|
|
163
163
|
...(to ? { to } : {}),
|
|
164
164
|
...(args['message'] ? { description: args['message'] } : {}),
|
|
165
165
|
...(maxClaims == null || to ? {} : { maxClaims }),
|
|
@@ -168,9 +168,17 @@ export const proposeLinkCapability = {
|
|
|
168
168
|
// back. That is the whole discipline: the caller knows whether it named
|
|
169
169
|
// somebody, so branching on it discloses nothing, while branching on
|
|
170
170
|
// anything the server learned about the target would.
|
|
171
|
+
//
|
|
172
|
+
// `agreementId` follows the same discipline: the route carries it for an
|
|
173
|
+
// agent id and an open invite and never for an email, so which of the two it
|
|
174
|
+
// is depends on what the caller passed and on nothing the server learned. It
|
|
175
|
+
// is what lets a caller reference the row and check on it; without it,
|
|
176
|
+
// several agents connecting in one run each minted a link toward the others
|
|
177
|
+
// in both directions, because none of them could ask.
|
|
171
178
|
return {
|
|
172
179
|
status: 'sent',
|
|
173
180
|
shareUrl,
|
|
181
|
+
agreementId,
|
|
174
182
|
message: to
|
|
175
183
|
? `Invitation sent to ${to}. You will not be told whether they already had an account, whether you were already connected, or whether this repeated an earlier invitation — the answer is the same in every case, on purpose. It becomes a live connection when they accept. ${linkIsReachOnly(env)}`
|
|
176
184
|
: `Share link created, valid 7 days. Give your human shareUrl and nothing else: it is the whole invite. ${linkIsReachOnly(env)}`,
|
|
@@ -34,7 +34,17 @@ export interface ProposeTerms {
|
|
|
34
34
|
/** 1:1 proposal to a specific user or agent (`proposedTo` = their id). */
|
|
35
35
|
export interface ProposeDirectInput extends ProposeTerms {
|
|
36
36
|
proposedTo: string;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The room the terms are discussed in, when there is one.
|
|
39
|
+
*
|
|
40
|
+
* Optional, matching the server, which has always accepted a proposal
|
|
41
|
+
* without one. Delivery does not depend on it: the create-time agreement
|
|
42
|
+
* event targets the party slots, so the counterparty is told either way; a
|
|
43
|
+
* room only gives them somewhere to talk about it. Required here was what
|
|
44
|
+
* forced a bookkeeping agreement between parties with no conversation to
|
|
45
|
+
* invent a chat id in order to exist.
|
|
46
|
+
*/
|
|
47
|
+
chatId?: string;
|
|
38
48
|
}
|
|
39
49
|
/**
|
|
40
50
|
* Open buyer-broadcast. `audience` selects who may see/claim it:
|
|
@@ -205,16 +215,29 @@ export interface CreateLinkBody {
|
|
|
205
215
|
maxClaims?: number;
|
|
206
216
|
}
|
|
207
217
|
/**
|
|
208
|
-
* The route's
|
|
218
|
+
* The route's answer.
|
|
219
|
+
*
|
|
220
|
+
* `status` is constant — it went — and the two null-able fields are per-ARM,
|
|
221
|
+
* chosen by what the caller put in `to` and therefore known to it before the
|
|
222
|
+
* call. `shareUrl` is present exactly for an open invite.
|
|
223
|
+
*
|
|
224
|
+
* `agreementId` names the row for an agent id and for an open invite, and is
|
|
225
|
+
* null for an EMAIL. That split is what keeps the answer from becoming an
|
|
226
|
+
* existence oracle: the knock returns nothing for "no such person", "already
|
|
227
|
+
* linked", "already knocked" and "that is yourself" alike, so an id on some of
|
|
228
|
+
* those and not others would answer the one question the route refuses to
|
|
229
|
+
* answer. An agent id is different — the route resolves it through root
|
|
230
|
+
* authority and refuses a name that answers to nobody, so it already discloses
|
|
231
|
+
* that target's existence.
|
|
209
232
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* user table, so both say the same thing now: it went. `shareUrl` is null
|
|
213
|
-
* exactly when `to` was named, which the caller already knows.
|
|
233
|
+
* Without it a caller could not reference what it had just created, check on it
|
|
234
|
+
* later, or tell a repeat from a first attempt.
|
|
214
235
|
*/
|
|
215
236
|
export interface CreateLinkResult {
|
|
216
237
|
ok: boolean;
|
|
217
238
|
status: 'sent';
|
|
239
|
+
/** The link row, for a named agent id or an open invite. Null for an email. */
|
|
240
|
+
agreementId: string | null;
|
|
218
241
|
shareUrl: string | null;
|
|
219
242
|
}
|
|
220
243
|
/**
|
|
@@ -444,9 +444,11 @@ export async function createLink(body, creds) {
|
|
|
444
444
|
throw new Error('Invalid response: expected { ok, status: "sent", shareUrl } from POST /agreements/links');
|
|
445
445
|
}
|
|
446
446
|
const shareUrl = data['shareUrl'];
|
|
447
|
+
const agreementId = data['agreementId'];
|
|
447
448
|
return {
|
|
448
449
|
ok: data['ok'] === true,
|
|
449
450
|
status: 'sent',
|
|
451
|
+
agreementId: typeof agreementId === 'string' ? agreementId : null,
|
|
450
452
|
shareUrl: typeof shareUrl === 'string' ? shareUrl : null,
|
|
451
453
|
};
|
|
452
454
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GrantView } from
|
|
1
|
+
import type { GrantView } from "./grants.js";
|
|
2
2
|
export declare function assertNoLeakedConnectionSecret(serialized: string): void;
|
|
3
3
|
/** Thrown by ConnectionsClient with the HTTP status and raw body attached. */
|
|
4
4
|
export interface ConnectionsError extends Error {
|
|
@@ -56,19 +56,28 @@ export declare class ConnectionsClient {
|
|
|
56
56
|
private readonly operatorKey;
|
|
57
57
|
private readonly agentId?;
|
|
58
58
|
private readonly baseUrl;
|
|
59
|
-
|
|
59
|
+
private readonly laneId?;
|
|
60
|
+
/**
|
|
61
|
+
* @param laneId the wake's lane (a chat id, or `agrn-<agreementId>`), sent as
|
|
62
|
+
* X-Ziggs-Lane. It tells the broker which engagement is spending a grant:
|
|
63
|
+
* an agent that serves several hirers holds all of their grants at once, so
|
|
64
|
+
* without a lane one hirer's grant is indistinguishable from another's, and
|
|
65
|
+
* a grant issued for one job can be spent inside a different one.
|
|
66
|
+
* ArtifactsClient sends the same header for the same reason.
|
|
67
|
+
*/
|
|
68
|
+
constructor(operatorKey: string, agentId?: string, baseUrl?: string, laneId?: string);
|
|
60
69
|
/**
|
|
61
70
|
* Present a ConnectionGrant to the broker and perform a provider action.
|
|
62
71
|
* Returns the provider result — never the raw OAuth token. Requires an
|
|
63
72
|
* impersonated agent (the grant holder).
|
|
64
73
|
*/
|
|
65
|
-
proxy({ connectionId, grantId, action, payload }: ConnectionProxyParams): Promise<unknown>;
|
|
74
|
+
proxy({ connectionId, grantId, action, payload, }: ConnectionProxyParams): Promise<unknown>;
|
|
66
75
|
/**
|
|
67
76
|
* List ConnectionGrants on a connection. When impersonating an agent the
|
|
68
77
|
* server already scopes rows to that holder; the client-side filter is kept
|
|
69
78
|
* as defense-in-depth (same behavior as the former ZiggsConnectClient).
|
|
70
79
|
*/
|
|
71
|
-
listGrants({ connectionId }: {
|
|
80
|
+
listGrants({ connectionId, }: {
|
|
72
81
|
connectionId: string;
|
|
73
82
|
}): Promise<ConnectionGrant[]>;
|
|
74
83
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { getBackendUrl } from
|
|
2
|
-
import { throwApiError } from
|
|
3
|
-
import { buildOperatorHeaders } from
|
|
4
|
-
import { GrantsClient } from
|
|
1
|
+
import { getBackendUrl } from "../utils/urlUtils.js";
|
|
2
|
+
import { throwApiError } from "../shared/apiError.js";
|
|
3
|
+
import { buildOperatorHeaders } from "./operatorHeaders.js";
|
|
4
|
+
import { GrantsClient } from "./GrantsClient.js";
|
|
5
5
|
// defense-in-depth mirror of the backend leak-guard
|
|
6
6
|
// (assertProxyResponseDoesNotLeakTokens). The backend strips the *specific*
|
|
7
7
|
// vault token from the response; clients never see that token, so this layer
|
|
@@ -17,7 +17,7 @@ const LEAKED_SECRET_PATTERNS = [
|
|
|
17
17
|
export function assertNoLeakedConnectionSecret(serialized) {
|
|
18
18
|
for (const re of LEAKED_SECRET_PATTERNS) {
|
|
19
19
|
if (re.test(serialized)) {
|
|
20
|
-
throw new Error(
|
|
20
|
+
throw new Error("connection proxy response withheld: it appears to contain a credential (token-leak guard)");
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -34,29 +34,39 @@ export class ConnectionsClient {
|
|
|
34
34
|
operatorKey;
|
|
35
35
|
agentId;
|
|
36
36
|
baseUrl;
|
|
37
|
-
|
|
37
|
+
laneId;
|
|
38
|
+
/**
|
|
39
|
+
* @param laneId the wake's lane (a chat id, or `agrn-<agreementId>`), sent as
|
|
40
|
+
* X-Ziggs-Lane. It tells the broker which engagement is spending a grant:
|
|
41
|
+
* an agent that serves several hirers holds all of their grants at once, so
|
|
42
|
+
* without a lane one hirer's grant is indistinguishable from another's, and
|
|
43
|
+
* a grant issued for one job can be spent inside a different one.
|
|
44
|
+
* ArtifactsClient sends the same header for the same reason.
|
|
45
|
+
*/
|
|
46
|
+
constructor(operatorKey, agentId, baseUrl, laneId) {
|
|
38
47
|
if (!operatorKey)
|
|
39
|
-
throw new Error(
|
|
48
|
+
throw new Error("ConnectionsClient: operatorKey is required");
|
|
40
49
|
this.operatorKey = operatorKey;
|
|
41
50
|
this.agentId = agentId;
|
|
42
51
|
this.baseUrl = baseUrl || getBackendUrl();
|
|
52
|
+
this.laneId = laneId;
|
|
43
53
|
}
|
|
44
54
|
/**
|
|
45
55
|
* Present a ConnectionGrant to the broker and perform a provider action.
|
|
46
56
|
* Returns the provider result — never the raw OAuth token. Requires an
|
|
47
57
|
* impersonated agent (the grant holder).
|
|
48
58
|
*/
|
|
49
|
-
async proxy({ connectionId, grantId, action, payload }) {
|
|
59
|
+
async proxy({ connectionId, grantId, action, payload, }) {
|
|
50
60
|
if (!connectionId)
|
|
51
|
-
throw new Error(
|
|
61
|
+
throw new Error("proxy: connectionId is required");
|
|
52
62
|
if (!grantId)
|
|
53
|
-
throw new Error(
|
|
63
|
+
throw new Error("proxy: grantId is required");
|
|
54
64
|
if (!action)
|
|
55
|
-
throw new Error(
|
|
65
|
+
throw new Error("proxy: action is required");
|
|
56
66
|
if (!this.agentId) {
|
|
57
|
-
throw new Error(
|
|
67
|
+
throw new Error("proxy: agentId is required — connection broker calls must impersonate the grant holder agent");
|
|
58
68
|
}
|
|
59
|
-
const text = await this._requestRaw(
|
|
69
|
+
const text = await this._requestRaw("POST", `/connections/${encodeURIComponent(connectionId)}/proxy`, { grantId, action, payload: payload ?? {} });
|
|
60
70
|
assertNoLeakedConnectionSecret(text);
|
|
61
71
|
let parsed = text;
|
|
62
72
|
try {
|
|
@@ -65,7 +75,7 @@ export class ConnectionsClient {
|
|
|
65
75
|
catch {
|
|
66
76
|
parsed = text;
|
|
67
77
|
}
|
|
68
|
-
const result = parsed?.[
|
|
78
|
+
const result = parsed?.["result"];
|
|
69
79
|
return result ?? parsed;
|
|
70
80
|
}
|
|
71
81
|
/**
|
|
@@ -73,15 +83,15 @@ export class ConnectionsClient {
|
|
|
73
83
|
* server already scopes rows to that holder; the client-side filter is kept
|
|
74
84
|
* as defense-in-depth (same behavior as the former ZiggsConnectClient).
|
|
75
85
|
*/
|
|
76
|
-
async listGrants({ connectionId }) {
|
|
86
|
+
async listGrants({ connectionId, }) {
|
|
77
87
|
if (!connectionId)
|
|
78
|
-
throw new Error(
|
|
88
|
+
throw new Error("listGrants: connectionId is required");
|
|
79
89
|
if (!this.agentId) {
|
|
80
|
-
throw new Error(
|
|
90
|
+
throw new Error("listGrants: agentId is required — grants are scoped to the impersonated agent");
|
|
81
91
|
}
|
|
82
|
-
const res = (await this._request(
|
|
83
|
-
const grants = res[
|
|
84
|
-
return grants.filter((g) => g[
|
|
92
|
+
const res = (await this._request("GET", `/connections/${encodeURIComponent(connectionId)}/grants`, undefined));
|
|
93
|
+
const grants = res["grants"] || [];
|
|
94
|
+
return grants.filter((g) => g["holderId"] === this.agentId);
|
|
85
95
|
}
|
|
86
96
|
/**
|
|
87
97
|
* cross-connection discovery over the unified GET /grants:
|
|
@@ -94,15 +104,15 @@ export class ConnectionsClient {
|
|
|
94
104
|
const grantsClient = new GrantsClient(this.operatorKey, this.agentId, this.baseUrl);
|
|
95
105
|
// All pages of the agent's live connection grants (not just the first page).
|
|
96
106
|
const { items, unreadableRails } = await grantsClient.listAllGrantsWithRails({
|
|
97
|
-
scopeKind:
|
|
98
|
-
health:
|
|
107
|
+
scopeKind: "connection",
|
|
108
|
+
health: "active",
|
|
99
109
|
});
|
|
100
110
|
// An unreadable rail is not an empty one. Returning [] here made
|
|
101
111
|
// every caller say "no connection grant" when the truth was "this key may not
|
|
102
112
|
// look" — the needs gate refused work the agent could do, and the MCP tool
|
|
103
113
|
// told the owner to issue a grant that already existed. Throwing puts the
|
|
104
114
|
// missing scope in the message, where the person who can fix it will read it.
|
|
105
|
-
const railBlocked = unreadableRails.find((r) => r.rail ===
|
|
115
|
+
const railBlocked = unreadableRails.find((r) => r.rail === "connection");
|
|
106
116
|
if (railBlocked && items.length === 0) {
|
|
107
117
|
throw new Error(`Cannot read this agent's connection grants: the operator key is missing the ` +
|
|
108
118
|
`"${railBlocked.requiredScope}" scope, so the grant list came back empty whether or not ` +
|
|
@@ -125,10 +135,10 @@ export class ConnectionsClient {
|
|
|
125
135
|
/** Issue a connection grant to an agent holder (connection owner side). */
|
|
126
136
|
async issueGrant({ connectionId, holderId, caveats, }) {
|
|
127
137
|
if (!connectionId)
|
|
128
|
-
throw new Error(
|
|
138
|
+
throw new Error("issueGrant: connectionId is required");
|
|
129
139
|
if (!holderId)
|
|
130
|
-
throw new Error(
|
|
131
|
-
return this._request(
|
|
140
|
+
throw new Error("issueGrant: holderId is required");
|
|
141
|
+
return this._request("POST", `/connections/${encodeURIComponent(connectionId)}/grants`, {
|
|
132
142
|
holderId,
|
|
133
143
|
caveats,
|
|
134
144
|
});
|
|
@@ -141,20 +151,20 @@ export class ConnectionsClient {
|
|
|
141
151
|
*/
|
|
142
152
|
async attenuateGrant({ connectionId, grantId, holderId, caveats, }) {
|
|
143
153
|
if (!connectionId)
|
|
144
|
-
throw new Error(
|
|
154
|
+
throw new Error("attenuateGrant: connectionId is required");
|
|
145
155
|
if (!grantId)
|
|
146
|
-
throw new Error(
|
|
156
|
+
throw new Error("attenuateGrant: grantId is required");
|
|
147
157
|
if (!holderId)
|
|
148
|
-
throw new Error(
|
|
149
|
-
return this._request(
|
|
158
|
+
throw new Error("attenuateGrant: holderId is required");
|
|
159
|
+
return this._request("POST", `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}/attenuate`, { holderId, caveats });
|
|
150
160
|
}
|
|
151
161
|
/** Revoke a single connection grant. */
|
|
152
162
|
async revokeGrant({ connectionId, grantId, }) {
|
|
153
163
|
if (!connectionId)
|
|
154
|
-
throw new Error(
|
|
164
|
+
throw new Error("revokeGrant: connectionId is required");
|
|
155
165
|
if (!grantId)
|
|
156
|
-
throw new Error(
|
|
157
|
-
return this._request(
|
|
166
|
+
throw new Error("revokeGrant: grantId is required");
|
|
167
|
+
return this._request("DELETE", `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}`, undefined);
|
|
158
168
|
}
|
|
159
169
|
/**
|
|
160
170
|
* agent-initiated MCP connection request: ask the principal to
|
|
@@ -164,21 +174,23 @@ export class ConnectionsClient {
|
|
|
164
174
|
*/
|
|
165
175
|
async requestMcpConnection({ serverUrl, tools, reason, chatId, onBehalfOfUserId, }) {
|
|
166
176
|
if (!serverUrl)
|
|
167
|
-
throw new Error(
|
|
168
|
-
return (await this._request(
|
|
177
|
+
throw new Error("requestMcpConnection: serverUrl is required");
|
|
178
|
+
return (await this._request("POST", "/connections/mcp/requests", { serverUrl, tools, reason, chatId }, onBehalfOfUserId
|
|
179
|
+
? { "X-On-Behalf-Of-User": onBehalfOfUserId }
|
|
180
|
+
: undefined));
|
|
169
181
|
}
|
|
170
182
|
async _requestRaw(method, path, body, extraHeaders) {
|
|
171
183
|
const init = {
|
|
172
184
|
method,
|
|
173
185
|
headers: buildOperatorHeaders(this.operatorKey, this.agentId, {
|
|
174
|
-
...(body !== undefined ? {
|
|
186
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
175
187
|
...extraHeaders,
|
|
176
|
-
}),
|
|
188
|
+
}, this.laneId),
|
|
177
189
|
};
|
|
178
190
|
if (body !== undefined)
|
|
179
191
|
init.body = JSON.stringify(body);
|
|
180
192
|
const response = await fetch(`${this.baseUrl}${path}`, init);
|
|
181
|
-
const text = await response.text().catch(() =>
|
|
193
|
+
const text = await response.text().catch(() => "");
|
|
182
194
|
if (!response.ok) {
|
|
183
195
|
// ApiError (status/body/code); ConnectionsError remains the
|
|
184
196
|
// documented duck type for callers that branch on `.status`.
|
|
@@ -16,6 +16,13 @@ export interface PublishOfferPayload {
|
|
|
16
16
|
/** Broadcast audience: 'everyone' (default, fully public) or 'org' (members of your active org only). */
|
|
17
17
|
audience?: BroadcastAudience;
|
|
18
18
|
metadata?: Record<string, unknown>;
|
|
19
|
+
/**
|
|
20
|
+
* Replay handle, sent as the `Idempotency-Key` header — the same way
|
|
21
|
+
* `proposeAgreement` sends it. Re-publishing under the same key resolves to
|
|
22
|
+
* the listing already on the board; a NEW key republishes and retires the
|
|
23
|
+
* previous one. It used to ride in the body on this one route.
|
|
24
|
+
*/
|
|
25
|
+
idempotencyKey?: string;
|
|
19
26
|
}
|
|
20
27
|
export declare function publishOffer(payload: PublishOfferPayload, creds: Creds): Promise<Agreement>;
|
|
21
28
|
export interface PullOffersOptions {
|
|
@@ -21,17 +21,26 @@ function assertCreds(creds, op) {
|
|
|
21
21
|
}
|
|
22
22
|
export async function publishOffer(payload, creds) {
|
|
23
23
|
assertCreds(creds, 'marketplace offer publish');
|
|
24
|
+
const { idempotencyKey, ...bodyData } = payload ?? {};
|
|
25
|
+
const headers = buildHeaders(creds);
|
|
26
|
+
if (idempotencyKey)
|
|
27
|
+
headers['Idempotency-Key'] = idempotencyKey;
|
|
24
28
|
const res = await fetch(`${getMarketplaceBaseUrl()}/offers/publish`, {
|
|
25
29
|
method: 'POST',
|
|
26
|
-
headers
|
|
27
|
-
body: JSON.stringify(
|
|
30
|
+
headers,
|
|
31
|
+
body: JSON.stringify(bodyData),
|
|
28
32
|
});
|
|
29
33
|
if (!res.ok) {
|
|
30
34
|
const body = await res.text().catch(() => '');
|
|
31
35
|
throwApiError(res, body, `Marketplace offer publish failed: ${res.status}`);
|
|
32
36
|
}
|
|
33
37
|
const data = await res.json().catch(() => null);
|
|
34
|
-
|
|
38
|
+
// One key name across every agreement-creating route. This used to read
|
|
39
|
+
// `offer`, which was this route's own name for the same thing.
|
|
40
|
+
if (!data?.['agreement']) {
|
|
41
|
+
throw new Error('Invalid response: expected { agreement } from POST /marketplace/offers/publish');
|
|
42
|
+
}
|
|
43
|
+
return shapeAgreement(data['agreement']);
|
|
35
44
|
}
|
|
36
45
|
export async function pullOffers(options, creds) {
|
|
37
46
|
assertCreds(creds, 'marketplace offers pull');
|
|
@@ -93,21 +93,39 @@ export declare class PaymentsClient {
|
|
|
93
93
|
userId?: string;
|
|
94
94
|
agentId?: string;
|
|
95
95
|
}): Promise<WalletRef | null>;
|
|
96
|
-
transfer({ to, amount, idempotencyKey, description, paymentGrantId, }: {
|
|
96
|
+
transfer({ to, amount, idempotencyKey, description, paymentGrantId, agreementId, }: {
|
|
97
97
|
to: string;
|
|
98
98
|
amount: number;
|
|
99
99
|
idempotencyKey?: string;
|
|
100
100
|
description?: string;
|
|
101
101
|
paymentGrantId?: string;
|
|
102
|
+
/** The engagement this spend belongs to, so its own grant is preferred. */
|
|
103
|
+
agreementId?: string;
|
|
102
104
|
}): Promise<TransferResult>;
|
|
103
|
-
hold({ amount, idempotencyKey, description, paymentGrantId, }: {
|
|
105
|
+
hold({ amount, idempotencyKey, description, paymentGrantId, agreementId, }: {
|
|
104
106
|
amount: number;
|
|
105
107
|
idempotencyKey?: string;
|
|
106
108
|
description?: string;
|
|
107
109
|
paymentGrantId?: string;
|
|
110
|
+
/** The engagement this spend belongs to, so its own grant is preferred. */
|
|
111
|
+
agreementId?: string;
|
|
108
112
|
}): Promise<HoldResult>;
|
|
109
|
-
/**
|
|
110
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Choose the grant that actually covers this spend.
|
|
115
|
+
*
|
|
116
|
+
* This used to be `grants[0]` — the first active grant, matched against
|
|
117
|
+
* nothing. An agent holding both its standing grant and an
|
|
118
|
+
* agreement-conferred one presented whichever came back first, the backend
|
|
119
|
+
* correctly refused it against wallet or amount, and the agent was refused a
|
|
120
|
+
* spend it was genuinely authorized to make.
|
|
121
|
+
*
|
|
122
|
+
* The source wallet is read rather than assumed: a grant on some other
|
|
123
|
+
* wallet cannot authorize money leaving this one, and the agent may hold
|
|
124
|
+
* grants on a counterparty's wallet from an engagement. A failure to read it
|
|
125
|
+
* is not fatal — selection then skips the wallet test and the server still
|
|
126
|
+
* validates, which is the same position we were in before.
|
|
127
|
+
*/
|
|
128
|
+
private selectPaymentGrant;
|
|
111
129
|
release({ holdId, action, toWalletId, idempotencyKey, }: {
|
|
112
130
|
holdId: string;
|
|
113
131
|
action?: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getBackendUrl } from '../utils/urlUtils.js';
|
|
2
2
|
import { buildOperatorHeaders } from './operatorHeaders.js';
|
|
3
3
|
import { GrantsClient } from './GrantsClient.js';
|
|
4
|
+
import { describeNoGrant, selectPaymentGrant, } from './paymentGrantSelection.js';
|
|
4
5
|
import { throwApiError } from '../shared/apiError.js';
|
|
5
6
|
function randomIdempotencyKey(prefix = 'op') {
|
|
6
7
|
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -45,18 +46,13 @@ export class PaymentsClient {
|
|
|
45
46
|
const res = (await this._get(`/payments/wallets/resolve?${params}`));
|
|
46
47
|
return res['wallet'] || null;
|
|
47
48
|
}
|
|
48
|
-
async transfer({ to, amount, idempotencyKey, description, paymentGrantId, }) {
|
|
49
|
+
async transfer({ to, amount, idempotencyKey, description, paymentGrantId, agreementId, }) {
|
|
49
50
|
if (!to)
|
|
50
51
|
throw new Error('transfer: `to` is required');
|
|
51
52
|
if (!(Number.isInteger(amount) && amount > 0))
|
|
52
53
|
throw new Error('transfer: `amount` must be a positive integer (cents)');
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
if (this.agentId && !paymentGrantId) {
|
|
56
|
-
paymentGrantId = (await this.resolveAgentPaymentGrantId()) ?? undefined;
|
|
57
|
-
}
|
|
58
|
-
if (this.agentId && !paymentGrantId)
|
|
59
|
-
throw new Error('transfer: paymentGrantId is required for agent-impersonated transfers. The wallet owner must have issued a payment grant to this agentId.');
|
|
54
|
+
// The recipient is resolved BEFORE choosing a grant: an `allowed_recipients`
|
|
55
|
+
// caveat cannot be checked against a name the client has not resolved yet.
|
|
60
56
|
let toWalletId = to;
|
|
61
57
|
if (!to.startsWith('wal_')) {
|
|
62
58
|
const w = await this.resolve(to.startsWith('agent_') ? { agentId: to } : { userId: to });
|
|
@@ -64,6 +60,15 @@ export class PaymentsClient {
|
|
|
64
60
|
throw new Error(`transfer: could not resolve wallet for "${to}"`);
|
|
65
61
|
toWalletId = w.walletId;
|
|
66
62
|
}
|
|
63
|
+
// Choose the grant that covers this spend when the caller omitted one.
|
|
64
|
+
// An explicit id still wins — a caller who names a grant means it.
|
|
65
|
+
if (this.agentId && !paymentGrantId) {
|
|
66
|
+
const picked = await this.selectPaymentGrant({ amount, toWalletId, agreementId });
|
|
67
|
+
if (!picked.grant) {
|
|
68
|
+
throw new Error(describeNoGrant('transfer', { amount, toWalletId, agreementId }, picked.rejected));
|
|
69
|
+
}
|
|
70
|
+
paymentGrantId = picked.grant.grantId;
|
|
71
|
+
}
|
|
67
72
|
const result = (await this._post('/payments/transfer', {
|
|
68
73
|
toWalletId,
|
|
69
74
|
amount,
|
|
@@ -90,15 +95,18 @@ export class PaymentsClient {
|
|
|
90
95
|
amount,
|
|
91
96
|
};
|
|
92
97
|
}
|
|
93
|
-
async hold({ amount, idempotencyKey, description, paymentGrantId, }) {
|
|
98
|
+
async hold({ amount, idempotencyKey, description, paymentGrantId, agreementId, }) {
|
|
94
99
|
if (!(Number.isInteger(amount) && amount > 0))
|
|
95
100
|
throw new Error('hold: `amount` must be a positive integer (cents)');
|
|
96
|
-
//
|
|
101
|
+
// A hold reserves the owner's money, so it needs a grant like a transfer.
|
|
102
|
+
// It has no counterparty, so no recipient is offered to the caveats.
|
|
97
103
|
if (this.agentId && !paymentGrantId) {
|
|
98
|
-
|
|
104
|
+
const picked = await this.selectPaymentGrant({ amount, agreementId });
|
|
105
|
+
if (!picked.grant) {
|
|
106
|
+
throw new Error(describeNoGrant('hold', { amount, agreementId }, picked.rejected));
|
|
107
|
+
}
|
|
108
|
+
paymentGrantId = picked.grant.grantId;
|
|
99
109
|
}
|
|
100
|
-
if (this.agentId && !paymentGrantId)
|
|
101
|
-
throw new Error('hold: paymentGrantId is required for agent-impersonated holds. The wallet owner must have issued a payment grant to this agentId.');
|
|
102
110
|
return (await this._post('/payments/hold', {
|
|
103
111
|
amount,
|
|
104
112
|
idempotencyKey: idempotencyKey || randomIdempotencyKey('hold'),
|
|
@@ -106,16 +114,37 @@ export class PaymentsClient {
|
|
|
106
114
|
paymentGrantId,
|
|
107
115
|
}));
|
|
108
116
|
}
|
|
109
|
-
/**
|
|
110
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Choose the grant that actually covers this spend.
|
|
119
|
+
*
|
|
120
|
+
* This used to be `grants[0]` — the first active grant, matched against
|
|
121
|
+
* nothing. An agent holding both its standing grant and an
|
|
122
|
+
* agreement-conferred one presented whichever came back first, the backend
|
|
123
|
+
* correctly refused it against wallet or amount, and the agent was refused a
|
|
124
|
+
* spend it was genuinely authorized to make.
|
|
125
|
+
*
|
|
126
|
+
* The source wallet is read rather than assumed: a grant on some other
|
|
127
|
+
* wallet cannot authorize money leaving this one, and the agent may hold
|
|
128
|
+
* grants on a counterparty's wallet from an engagement. A failure to read it
|
|
129
|
+
* is not fatal — selection then skips the wallet test and the server still
|
|
130
|
+
* validates, which is the same position we were in before.
|
|
131
|
+
*/
|
|
132
|
+
async selectPaymentGrant(spend) {
|
|
133
|
+
let grants = [];
|
|
134
|
+
try {
|
|
135
|
+
grants = await this.listGrants();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return { grant: null, rejected: [] };
|
|
139
|
+
}
|
|
140
|
+
let fromWalletId = null;
|
|
111
141
|
try {
|
|
112
|
-
|
|
113
|
-
const id = grants[0]?.grantId;
|
|
114
|
-
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
142
|
+
fromWalletId = (await this.balance()).walletId;
|
|
115
143
|
}
|
|
116
144
|
catch {
|
|
117
|
-
|
|
145
|
+
fromWalletId = null;
|
|
118
146
|
}
|
|
147
|
+
return selectPaymentGrant(grants, { ...spend, fromWalletId });
|
|
119
148
|
}
|
|
120
149
|
async release({ holdId, action = 'complete', toWalletId, idempotencyKey, }) {
|
|
121
150
|
return (await this._post(`/payments/release/${holdId}`, {
|
|
@@ -38,12 +38,18 @@ export async function proposeUnified(input, creds) {
|
|
|
38
38
|
}, creds);
|
|
39
39
|
return { agreement, shape: 'request' };
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// No room is a shape, not an error. The server has always accepted a direct
|
|
42
|
+
// proposal without one, and this throw was the only thing that made the answer
|
|
43
|
+
// depend on which client you used: an agreement between parties who have no
|
|
44
|
+
// conversation had to invent a chat id to be created. Delivery does not go
|
|
45
|
+
// through the room — the create-time event targets the party slots — so the
|
|
46
|
+
// counterparty is told either way.
|
|
43
47
|
const agreement = await proposeDirectTo({
|
|
44
48
|
...terms,
|
|
45
49
|
proposedTo,
|
|
46
|
-
|
|
50
|
+
// Omitted rather than sent empty: an absent field says "no room", and an
|
|
51
|
+
// empty string is one more fiction for the server to interpret.
|
|
52
|
+
...(chatId ? { chatId } : {}),
|
|
47
53
|
providerId: providerId?.trim() || proposedTo,
|
|
48
54
|
engagementKind: engagementKind ?? 'service',
|
|
49
55
|
}, creds);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type GrantView } from './grants.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which grant to present for a spend, and — when none fits — why each candidate
|
|
4
|
+
* did not.
|
|
5
|
+
*
|
|
6
|
+
* The client used to take `grants[0]` from the active list. It matched nothing
|
|
7
|
+
* else: not the source wallet, not the amount, not the agreement the spend
|
|
8
|
+
* belongs to. An agent holding both its standing grant and an agreement-conferred
|
|
9
|
+
* one presented whichever came back first, the backend correctly refused it
|
|
10
|
+
* against wallet or amount, and the agent was refused a spend it was genuinely
|
|
11
|
+
* authorized to make.
|
|
12
|
+
*
|
|
13
|
+
* Selection is a pure function of the grants and the spend so it can be tested
|
|
14
|
+
* without a server, and so the refusal can name what it considered.
|
|
15
|
+
*/
|
|
16
|
+
export interface SpendShape {
|
|
17
|
+
/** The wallet the money leaves. A grant on another wallet cannot authorize it. */
|
|
18
|
+
fromWalletId?: string | null;
|
|
19
|
+
/** Cents. Checked against `max_amount`. */
|
|
20
|
+
amount: number;
|
|
21
|
+
/** The wallet receiving it, when known. Checked against `allowed_recipients`. */
|
|
22
|
+
toWalletId?: string | null;
|
|
23
|
+
/** The engagement this spend belongs to, when the caller names one. */
|
|
24
|
+
agreementId?: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface RejectedGrant {
|
|
27
|
+
grantId: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
export interface GrantSelection {
|
|
31
|
+
grant: GrantView | null;
|
|
32
|
+
/** Every grant that was looked at and did not fit, with the reason. */
|
|
33
|
+
rejected: RejectedGrant[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Pick the grant to present, preferring the one that belongs to the engagement.
|
|
37
|
+
*
|
|
38
|
+
* Order among grants that all fit: the agreement's own grant first when the
|
|
39
|
+
* spend names one, then the standing grant. A standing grant is the buyer
|
|
40
|
+
* bootstrap and the right default; reaching for it while an agreement grant
|
|
41
|
+
* covers the same spend would bill the wrong budget.
|
|
42
|
+
*/
|
|
43
|
+
export declare function selectPaymentGrant(grants: readonly GrantView[], spend: SpendShape): GrantSelection;
|
|
44
|
+
/**
|
|
45
|
+
* The sentence an agent gets when nothing fits.
|
|
46
|
+
*
|
|
47
|
+
* It names every grant considered and why each was not it, because "no payment
|
|
48
|
+
* grant" is a condition and an agent cannot act on a condition — it retries,
|
|
49
|
+
* and every retry is a full model call. Which ceiling was in the way is the
|
|
50
|
+
* thing that tells the agent whether to ask for a bigger grant, name the
|
|
51
|
+
* agreement, or stop.
|
|
52
|
+
*/
|
|
53
|
+
export declare function describeNoGrant(verb: string, spend: SpendShape, rejected: readonly RejectedGrant[]): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { grantCaveat } from './grants.js';
|
|
2
|
+
/**
|
|
3
|
+
* A caveat off a grant that may not carry the array at all.
|
|
4
|
+
*
|
|
5
|
+
* `grantCaveat` assumes `caveats` is populated, which the unified list read
|
|
6
|
+
* does. A caller handing us a partial view should get "no such caveat", not a
|
|
7
|
+
* crash — this runs on the spend path, where throwing would turn a missing
|
|
8
|
+
* field into a refused payment.
|
|
9
|
+
*/
|
|
10
|
+
function caveatValue(grant, type) {
|
|
11
|
+
const caveats = grant.caveats;
|
|
12
|
+
if (!Array.isArray(caveats))
|
|
13
|
+
return undefined;
|
|
14
|
+
return grantCaveat(grant, type);
|
|
15
|
+
}
|
|
16
|
+
function numericCaveat(grant, type) {
|
|
17
|
+
const raw = caveatValue(grant, type);
|
|
18
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
|
|
19
|
+
}
|
|
20
|
+
function recipientCaveat(grant) {
|
|
21
|
+
const raw = caveatValue(grant, 'allowed_recipients');
|
|
22
|
+
if (!Array.isArray(raw))
|
|
23
|
+
return null;
|
|
24
|
+
const ids = raw.filter((r) => typeof r === 'string');
|
|
25
|
+
return ids.length ? ids : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Why this grant cannot authorize this spend, or null if it can.
|
|
29
|
+
*
|
|
30
|
+
* `daily_budget` is deliberately not checked. It is a ceiling on spend already
|
|
31
|
+
* made, which only the server can see — guessing here would refuse a grant that
|
|
32
|
+
* would have worked. The server validates it inside the same transaction as the
|
|
33
|
+
* ledger write, which is the only place the answer is not already stale.
|
|
34
|
+
*/
|
|
35
|
+
function whyNot(grant, spend) {
|
|
36
|
+
// A stated kind that is not `wallet` is disqualifying. An ABSENT one is not:
|
|
37
|
+
// `listGrants` asks the server for `scopeKind: 'wallet'`, so the rail has
|
|
38
|
+
// already answered, and treating a field the response did not populate as a
|
|
39
|
+
// wrong answer would refuse every grant. Same reading as an unread source
|
|
40
|
+
// wallet below — absence means unknown, never no.
|
|
41
|
+
if (grant.scope?.kind && grant.scope.kind !== 'wallet') {
|
|
42
|
+
return `is a ${grant.scope.kind} grant, not a wallet grant`;
|
|
43
|
+
}
|
|
44
|
+
if (spend.fromWalletId && grant.scope?.id && grant.scope.id !== spend.fromWalletId) {
|
|
45
|
+
return `is on wallet ${grant.scope.id}, and this spend leaves ${spend.fromWalletId}`;
|
|
46
|
+
}
|
|
47
|
+
const maxAmount = numericCaveat(grant, 'max_amount');
|
|
48
|
+
if (maxAmount !== null && spend.amount > maxAmount) {
|
|
49
|
+
return `caps a single spend at ${maxAmount}, and this one is ${spend.amount}`;
|
|
50
|
+
}
|
|
51
|
+
const allowed = recipientCaveat(grant);
|
|
52
|
+
if (allowed && spend.toWalletId && !allowed.includes(spend.toWalletId)) {
|
|
53
|
+
return `may only pay ${allowed.join(', ')}, and this one pays ${spend.toWalletId}`;
|
|
54
|
+
}
|
|
55
|
+
// An agreement-conferred grant is FOR that agreement's work. Spending it on
|
|
56
|
+
// something else would pass every caveat above and still be the wrong money:
|
|
57
|
+
// the ceiling was agreed for one engagement, and nothing on the wire would
|
|
58
|
+
// show it had been used for another.
|
|
59
|
+
if (grant.agreementId) {
|
|
60
|
+
if (!spend.agreementId) {
|
|
61
|
+
return `belongs to agreement ${grant.agreementId}, and this spend names no agreement`;
|
|
62
|
+
}
|
|
63
|
+
if (grant.agreementId !== spend.agreementId) {
|
|
64
|
+
return `belongs to agreement ${grant.agreementId}, not ${spend.agreementId}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Pick the grant to present, preferring the one that belongs to the engagement.
|
|
71
|
+
*
|
|
72
|
+
* Order among grants that all fit: the agreement's own grant first when the
|
|
73
|
+
* spend names one, then the standing grant. A standing grant is the buyer
|
|
74
|
+
* bootstrap and the right default; reaching for it while an agreement grant
|
|
75
|
+
* covers the same spend would bill the wrong budget.
|
|
76
|
+
*/
|
|
77
|
+
export function selectPaymentGrant(grants, spend) {
|
|
78
|
+
const rejected = [];
|
|
79
|
+
const fitting = [];
|
|
80
|
+
for (const grant of grants) {
|
|
81
|
+
const reason = whyNot(grant, spend);
|
|
82
|
+
if (reason)
|
|
83
|
+
rejected.push({ grantId: grant.grantId, reason });
|
|
84
|
+
else
|
|
85
|
+
fitting.push(grant);
|
|
86
|
+
}
|
|
87
|
+
if (fitting.length === 0)
|
|
88
|
+
return { grant: null, rejected };
|
|
89
|
+
const forAgreement = spend.agreementId
|
|
90
|
+
? fitting.find((g) => g.agreementId === spend.agreementId)
|
|
91
|
+
: undefined;
|
|
92
|
+
return { grant: forAgreement ?? fitting[0], rejected };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The sentence an agent gets when nothing fits.
|
|
96
|
+
*
|
|
97
|
+
* It names every grant considered and why each was not it, because "no payment
|
|
98
|
+
* grant" is a condition and an agent cannot act on a condition — it retries,
|
|
99
|
+
* and every retry is a full model call. Which ceiling was in the way is the
|
|
100
|
+
* thing that tells the agent whether to ask for a bigger grant, name the
|
|
101
|
+
* agreement, or stop.
|
|
102
|
+
*/
|
|
103
|
+
export function describeNoGrant(verb, spend, rejected) {
|
|
104
|
+
if (rejected.length === 0) {
|
|
105
|
+
return (`${verb}: you hold no active payment grant, so this spend cannot be ` +
|
|
106
|
+
`authorized. The wallet owner issues one; nothing you can do from here.`);
|
|
107
|
+
}
|
|
108
|
+
const lines = rejected.map((r) => ` - ${r.grantId} ${r.reason}`).join('\n');
|
|
109
|
+
return (`${verb}: none of your ${rejected.length} active payment grant(s) covers ` +
|
|
110
|
+
`this spend of ${spend.amount}.\n${lines}`);
|
|
111
|
+
}
|