@ziggs-ai/api-client 0.14.3 → 0.16.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/dist/capabilities/agreementVerbs.d.ts +9 -0
- package/dist/capabilities/agreementVerbs.js +29 -3
- package/dist/capabilities/agreements.js +2 -1
- package/dist/capabilities/artifacts.js +16 -6
- package/dist/capabilities/connections.d.ts +1 -1
- package/dist/capabilities/connections.js +82 -59
- package/dist/capabilities/grants.js +8 -2
- package/dist/capabilities/index.d.ts +1 -0
- package/dist/capabilities/index.js +1 -0
- package/dist/capabilities/links.js +12 -4
- package/dist/capabilities/listedFields.d.ts +17 -0
- package/dist/capabilities/listedFields.js +45 -0
- package/dist/capabilities/marketplace.js +7 -2
- package/dist/capabilities/payments.js +7 -2
- package/dist/capabilities/tasks.js +9 -3
- package/dist/http/AgreementClient.d.ts +29 -6
- package/dist/http/AgreementClient.js +2 -0
- package/dist/http/ArtifactsClient.d.ts +5 -16
- package/dist/http/ArtifactsClient.js +8 -5
- package/dist/http/ChatClient.d.ts +2 -6
- package/dist/http/ConnectionsClient.d.ts +15 -4
- package/dist/http/ConnectionsClient.js +53 -39
- package/dist/http/GrantsClient.d.ts +10 -1
- package/dist/http/GrantsClient.js +12 -2
- package/dist/http/IntroductionsClient.d.ts +11 -4
- package/dist/http/MarketplaceClient.d.ts +7 -0
- package/dist/http/MarketplaceClient.js +12 -3
- package/dist/http/OrgsClient.d.ts +0 -14
- package/dist/http/OrgsClient.js +2 -17
- package/dist/http/PaymentsClient.d.ts +47 -5
- package/dist/http/PaymentsClient.js +66 -22
- package/dist/http/TaskClient.js +1 -1
- package/dist/http/agreementFlows.js +9 -3
- package/dist/http/index.d.ts +1 -1
- package/dist/http/index.js +1 -1
- package/dist/http/paymentGrantSelection.d.ts +58 -0
- package/dist/http/paymentGrantSelection.js +121 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/shared/operatorKey.d.ts +47 -0
- package/dist/shared/operatorKey.js +64 -0
- package/dist/types.d.ts +6 -26
- package/dist/types.js +1 -19
- package/dist/utils/appUrls.d.ts +7 -0
- package/dist/utils/appUrls.js +38 -0
- package/package.json +3 -2
|
@@ -87,27 +87,69 @@ export declare class PaymentsClient {
|
|
|
87
87
|
private readonly operatorKey;
|
|
88
88
|
private readonly agentId?;
|
|
89
89
|
private readonly baseUrl;
|
|
90
|
-
|
|
90
|
+
private readonly laneId?;
|
|
91
|
+
/**
|
|
92
|
+
* @param laneId the wake's lane (a chat id, or `agrn-<agreementId>`), sent as
|
|
93
|
+
* X-Ziggs-Lane. It tells the backend which hire this wake is in, and the
|
|
94
|
+
* backend reads off it WHO the spend is for. An agent that serves several
|
|
95
|
+
* hirers holds all of their grants at once and the holder is the same agent
|
|
96
|
+
* in every one, so without a lane, spend authority one customer gave is
|
|
97
|
+
* spendable while the agent works for another. ConnectionsClient and
|
|
98
|
+
* ArtifactsClient send the same header for the same reason.
|
|
99
|
+
*
|
|
100
|
+
* Omitting it cannot widen anything — a stated lane can only cause a
|
|
101
|
+
* refusal — but it does leave the spend unfenced, so a caller that knows
|
|
102
|
+
* its lane should pass it.
|
|
103
|
+
*/
|
|
104
|
+
constructor(operatorKey: string, agentId?: string, baseUrl?: string, laneId?: string);
|
|
91
105
|
balance(): Promise<WalletBalance>;
|
|
92
106
|
resolve({ userId, agentId }?: {
|
|
93
107
|
userId?: string;
|
|
94
108
|
agentId?: string;
|
|
95
109
|
}): Promise<WalletRef | null>;
|
|
96
|
-
transfer({ to, amount, idempotencyKey, description, paymentGrantId, }: {
|
|
110
|
+
transfer({ to, amount, idempotencyKey, description, paymentGrantId, agreementId, }: {
|
|
97
111
|
to: string;
|
|
98
112
|
amount: number;
|
|
99
113
|
idempotencyKey?: string;
|
|
100
114
|
description?: string;
|
|
101
115
|
paymentGrantId?: string;
|
|
116
|
+
/**
|
|
117
|
+
* The hire this spend belongs to. Used for attribution, and to prefer that
|
|
118
|
+
* hire's own grant over a standing one — a preference about which budget is
|
|
119
|
+
* billed, never about which grants may be spent. That answer is the
|
|
120
|
+
* server's, and it turns on who gave the grant.
|
|
121
|
+
*/
|
|
122
|
+
agreementId?: string;
|
|
102
123
|
}): Promise<TransferResult>;
|
|
103
|
-
hold({ amount, idempotencyKey, description, paymentGrantId, }: {
|
|
124
|
+
hold({ amount, idempotencyKey, description, paymentGrantId, agreementId, }: {
|
|
104
125
|
amount: number;
|
|
105
126
|
idempotencyKey?: string;
|
|
106
127
|
description?: string;
|
|
107
128
|
paymentGrantId?: string;
|
|
129
|
+
/**
|
|
130
|
+
* The hire this spend belongs to. Used for attribution, and to prefer that
|
|
131
|
+
* hire's own grant over a standing one — a preference about which budget is
|
|
132
|
+
* billed, never about which grants may be spent. That answer is the
|
|
133
|
+
* server's, and it turns on who gave the grant.
|
|
134
|
+
*/
|
|
135
|
+
agreementId?: string;
|
|
108
136
|
}): Promise<HoldResult>;
|
|
109
|
-
/**
|
|
110
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Choose the grant that actually covers this spend.
|
|
139
|
+
*
|
|
140
|
+
* This used to be `grants[0]` — the first active grant, matched against
|
|
141
|
+
* nothing. An agent holding both its standing grant and an
|
|
142
|
+
* agreement-conferred one presented whichever came back first, the backend
|
|
143
|
+
* correctly refused it against wallet or amount, and the agent was refused a
|
|
144
|
+
* spend it was genuinely authorized to make.
|
|
145
|
+
*
|
|
146
|
+
* The source wallet is read rather than assumed: a grant on some other
|
|
147
|
+
* wallet cannot authorize money leaving this one, and the agent may hold
|
|
148
|
+
* grants on a counterparty's wallet from an engagement. A failure to read it
|
|
149
|
+
* is not fatal — selection then skips the wallet test and the server still
|
|
150
|
+
* validates, which is the same position we were in before.
|
|
151
|
+
*/
|
|
152
|
+
private selectPaymentGrant;
|
|
111
153
|
release({ holdId, action, toWalletId, idempotencyKey, }: {
|
|
112
154
|
holdId: string;
|
|
113
155
|
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)}`;
|
|
@@ -17,12 +18,27 @@ export class PaymentsClient {
|
|
|
17
18
|
operatorKey;
|
|
18
19
|
agentId;
|
|
19
20
|
baseUrl;
|
|
20
|
-
|
|
21
|
+
laneId;
|
|
22
|
+
/**
|
|
23
|
+
* @param laneId the wake's lane (a chat id, or `agrn-<agreementId>`), sent as
|
|
24
|
+
* X-Ziggs-Lane. It tells the backend which hire this wake is in, and the
|
|
25
|
+
* backend reads off it WHO the spend is for. An agent that serves several
|
|
26
|
+
* hirers holds all of their grants at once and the holder is the same agent
|
|
27
|
+
* in every one, so without a lane, spend authority one customer gave is
|
|
28
|
+
* spendable while the agent works for another. ConnectionsClient and
|
|
29
|
+
* ArtifactsClient send the same header for the same reason.
|
|
30
|
+
*
|
|
31
|
+
* Omitting it cannot widen anything — a stated lane can only cause a
|
|
32
|
+
* refusal — but it does leave the spend unfenced, so a caller that knows
|
|
33
|
+
* its lane should pass it.
|
|
34
|
+
*/
|
|
35
|
+
constructor(operatorKey, agentId, baseUrl, laneId) {
|
|
21
36
|
if (!operatorKey)
|
|
22
37
|
throw new Error('PaymentsClient: operatorKey is required');
|
|
23
38
|
this.operatorKey = operatorKey;
|
|
24
39
|
this.agentId = agentId;
|
|
25
40
|
this.baseUrl = baseUrl || getBackendUrl();
|
|
41
|
+
this.laneId = laneId;
|
|
26
42
|
}
|
|
27
43
|
async balance() {
|
|
28
44
|
const w = (await this._get('/payments/wallet'));
|
|
@@ -45,18 +61,13 @@ export class PaymentsClient {
|
|
|
45
61
|
const res = (await this._get(`/payments/wallets/resolve?${params}`));
|
|
46
62
|
return res['wallet'] || null;
|
|
47
63
|
}
|
|
48
|
-
async transfer({ to, amount, idempotencyKey, description, paymentGrantId, }) {
|
|
64
|
+
async transfer({ to, amount, idempotencyKey, description, paymentGrantId, agreementId, }) {
|
|
49
65
|
if (!to)
|
|
50
66
|
throw new Error('transfer: `to` is required');
|
|
51
67
|
if (!(Number.isInteger(amount) && amount > 0))
|
|
52
68
|
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.');
|
|
69
|
+
// The recipient is resolved BEFORE choosing a grant: an `allowed_recipients`
|
|
70
|
+
// caveat cannot be checked against a name the client has not resolved yet.
|
|
60
71
|
let toWalletId = to;
|
|
61
72
|
if (!to.startsWith('wal_')) {
|
|
62
73
|
const w = await this.resolve(to.startsWith('agent_') ? { agentId: to } : { userId: to });
|
|
@@ -64,6 +75,15 @@ export class PaymentsClient {
|
|
|
64
75
|
throw new Error(`transfer: could not resolve wallet for "${to}"`);
|
|
65
76
|
toWalletId = w.walletId;
|
|
66
77
|
}
|
|
78
|
+
// Choose the grant that covers this spend when the caller omitted one.
|
|
79
|
+
// An explicit id still wins — a caller who names a grant means it.
|
|
80
|
+
if (this.agentId && !paymentGrantId) {
|
|
81
|
+
const picked = await this.selectPaymentGrant({ amount, toWalletId, agreementId });
|
|
82
|
+
if (!picked.grant) {
|
|
83
|
+
throw new Error(describeNoGrant('transfer', { amount, toWalletId, agreementId }, picked.rejected));
|
|
84
|
+
}
|
|
85
|
+
paymentGrantId = picked.grant.grantId;
|
|
86
|
+
}
|
|
67
87
|
const result = (await this._post('/payments/transfer', {
|
|
68
88
|
toWalletId,
|
|
69
89
|
amount,
|
|
@@ -90,15 +110,18 @@ export class PaymentsClient {
|
|
|
90
110
|
amount,
|
|
91
111
|
};
|
|
92
112
|
}
|
|
93
|
-
async hold({ amount, idempotencyKey, description, paymentGrantId, }) {
|
|
113
|
+
async hold({ amount, idempotencyKey, description, paymentGrantId, agreementId, }) {
|
|
94
114
|
if (!(Number.isInteger(amount) && amount > 0))
|
|
95
115
|
throw new Error('hold: `amount` must be a positive integer (cents)');
|
|
96
|
-
//
|
|
116
|
+
// A hold reserves the owner's money, so it needs a grant like a transfer.
|
|
117
|
+
// It has no counterparty, so no recipient is offered to the caveats.
|
|
97
118
|
if (this.agentId && !paymentGrantId) {
|
|
98
|
-
|
|
119
|
+
const picked = await this.selectPaymentGrant({ amount, agreementId });
|
|
120
|
+
if (!picked.grant) {
|
|
121
|
+
throw new Error(describeNoGrant('hold', { amount, agreementId }, picked.rejected));
|
|
122
|
+
}
|
|
123
|
+
paymentGrantId = picked.grant.grantId;
|
|
99
124
|
}
|
|
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
125
|
return (await this._post('/payments/hold', {
|
|
103
126
|
amount,
|
|
104
127
|
idempotencyKey: idempotencyKey || randomIdempotencyKey('hold'),
|
|
@@ -106,16 +129,37 @@ export class PaymentsClient {
|
|
|
106
129
|
paymentGrantId,
|
|
107
130
|
}));
|
|
108
131
|
}
|
|
109
|
-
/**
|
|
110
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Choose the grant that actually covers this spend.
|
|
134
|
+
*
|
|
135
|
+
* This used to be `grants[0]` — the first active grant, matched against
|
|
136
|
+
* nothing. An agent holding both its standing grant and an
|
|
137
|
+
* agreement-conferred one presented whichever came back first, the backend
|
|
138
|
+
* correctly refused it against wallet or amount, and the agent was refused a
|
|
139
|
+
* spend it was genuinely authorized to make.
|
|
140
|
+
*
|
|
141
|
+
* The source wallet is read rather than assumed: a grant on some other
|
|
142
|
+
* wallet cannot authorize money leaving this one, and the agent may hold
|
|
143
|
+
* grants on a counterparty's wallet from an engagement. A failure to read it
|
|
144
|
+
* is not fatal — selection then skips the wallet test and the server still
|
|
145
|
+
* validates, which is the same position we were in before.
|
|
146
|
+
*/
|
|
147
|
+
async selectPaymentGrant(spend) {
|
|
148
|
+
let grants = [];
|
|
149
|
+
try {
|
|
150
|
+
grants = await this.listGrants();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { grant: null, rejected: [] };
|
|
154
|
+
}
|
|
155
|
+
let fromWalletId = null;
|
|
111
156
|
try {
|
|
112
|
-
|
|
113
|
-
const id = grants[0]?.grantId;
|
|
114
|
-
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
157
|
+
fromWalletId = (await this.balance()).walletId;
|
|
115
158
|
}
|
|
116
159
|
catch {
|
|
117
|
-
|
|
160
|
+
fromWalletId = null;
|
|
118
161
|
}
|
|
162
|
+
return selectPaymentGrant(grants, { ...spend, fromWalletId });
|
|
119
163
|
}
|
|
120
164
|
async release({ holdId, action = 'complete', toWalletId, idempotencyKey, }) {
|
|
121
165
|
return (await this._post(`/payments/release/${holdId}`, {
|
|
@@ -151,7 +195,7 @@ export class PaymentsClient {
|
|
|
151
195
|
* (retired GET /payments/grants), following the cursor to completion.
|
|
152
196
|
*/
|
|
153
197
|
async listGrants() {
|
|
154
|
-
const grants = new GrantsClient(this.operatorKey, this.agentId, this.baseUrl);
|
|
198
|
+
const grants = new GrantsClient(this.operatorKey, this.agentId, this.baseUrl, this.laneId);
|
|
155
199
|
return grants.listAllGrants({ scopeKind: 'wallet', health: 'active' });
|
|
156
200
|
}
|
|
157
201
|
async createTopUpIntent({ amount, description, currency, } = {}) {
|
|
@@ -232,7 +276,7 @@ export class PaymentsClient {
|
|
|
232
276
|
async _request(method, path, body) {
|
|
233
277
|
const init = {
|
|
234
278
|
method,
|
|
235
|
-
headers: buildOperatorHeaders(this.operatorKey, this.agentId, body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
279
|
+
headers: buildOperatorHeaders(this.operatorKey, this.agentId, body !== undefined ? { 'content-type': 'application/json' } : {}, this.laneId),
|
|
236
280
|
};
|
|
237
281
|
if (body !== undefined)
|
|
238
282
|
init.body = JSON.stringify(body);
|
package/dist/http/TaskClient.js
CHANGED
|
@@ -165,7 +165,7 @@ export async function getActiveTasksForAgent(agentId, creds) {
|
|
|
165
165
|
* assignee's own work list hands over a task nobody woke it for. Every caller of
|
|
166
166
|
* this function is asking "what can I act on now", and for a withheld row the
|
|
167
167
|
* answer is nothing — `updateTaskState` refuses it with `dependencies_unmet`.
|
|
168
|
-
* Two of
|
|
168
|
+
* Two of Agent's three callers do worse than waste a wake: the needs gate
|
|
169
169
|
* and the unmet-access sweep both FAIL the tasks they find, so a withheld node
|
|
170
170
|
* was one missing grant away from being failed before its turn came.
|
|
171
171
|
*
|
|
@@ -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);
|
package/dist/http/index.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { PaymentsClient } from './PaymentsClient.js';
|
|
|
23
23
|
export type { PaymentsError, WalletBalance, WalletRef, PaymentTransactionView, TransferResult, HoldResult, ReleaseResult, PaymentGrantView, PaymentGrantEnvelope, RevokeGrantResult, PaymentApproval, WaitForApprovalResult, } from './PaymentsClient.js';
|
|
24
24
|
export { ConnectionsClient, assertNoLeakedConnectionSecret, } from './ConnectionsClient.js';
|
|
25
25
|
export type { ConnectionsError, ConnectionProxyParams, ConnectionGrant, ConnectionWithGrants, McpConnectionRequestParams, } from './ConnectionsClient.js';
|
|
26
|
-
export { fetchMyOrgs, resolveOrgSelector, fetchDelegateAccess, fetchHostedAgentAccess, fetchSessionAccess,
|
|
26
|
+
export { fetchMyOrgs, resolveOrgSelector, fetchDelegateAccess, fetchHostedAgentAccess, fetchSessionAccess, } from './OrgsClient.js';
|
|
27
27
|
export type { MyOrg, OrgResolution } from './OrgsClient.js';
|
|
28
28
|
export { AgentSearchClient } from './AgentSearchClient.js';
|
|
29
29
|
export { TelemetryClient } from './TelemetryClient.js';
|
package/dist/http/index.js
CHANGED
|
@@ -15,7 +15,7 @@ export { ContextGrantsClient } from './ContextGrantsClient.js';
|
|
|
15
15
|
export { grantCaveat, CONTEXT_GRANT_SCOPE_KINDS, GRANT_SCOPE_KINDS, } from './grants.js';
|
|
16
16
|
export { PaymentsClient } from './PaymentsClient.js';
|
|
17
17
|
export { ConnectionsClient, assertNoLeakedConnectionSecret, } from './ConnectionsClient.js';
|
|
18
|
-
export { fetchMyOrgs, resolveOrgSelector, fetchDelegateAccess, fetchHostedAgentAccess, fetchSessionAccess,
|
|
18
|
+
export { fetchMyOrgs, resolveOrgSelector, fetchDelegateAccess, fetchHostedAgentAccess, fetchSessionAccess, } from './OrgsClient.js';
|
|
19
19
|
export { AgentSearchClient } from './AgentSearchClient.js';
|
|
20
20
|
export { TelemetryClient } from './TelemetryClient.js';
|
|
21
21
|
export { InboxClient } from './InboxClient.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
* This is a preference, never a fence. Every grant here is already one the
|
|
44
|
+
* server said this wake may spend, so picking a different one cannot reach
|
|
45
|
+
* another party's authority — it would only bill the wrong budget of the same
|
|
46
|
+
* party. That is why the mismatch is no longer a rejection above.
|
|
47
|
+
*/
|
|
48
|
+
export declare function selectPaymentGrant(grants: readonly GrantView[], spend: SpendShape): GrantSelection;
|
|
49
|
+
/**
|
|
50
|
+
* The sentence an agent gets when nothing fits.
|
|
51
|
+
*
|
|
52
|
+
* It names every grant considered and why each was not it, because "no payment
|
|
53
|
+
* grant" is a condition and an agent cannot act on a condition — it retries,
|
|
54
|
+
* and every retry is a full model call. Which ceiling was in the way is the
|
|
55
|
+
* thing that tells the agent whether to ask for a bigger grant, name the
|
|
56
|
+
* agreement, or stop.
|
|
57
|
+
*/
|
|
58
|
+
export declare function describeNoGrant(verb: string, spend: SpendShape, rejected: readonly RejectedGrant[]): string;
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
// A grant's agreement is NOT checked here, deliberately.
|
|
56
|
+
//
|
|
57
|
+
// It used to be refused when it did not match the spend's agreement, which
|
|
58
|
+
// made a customer's own grant unusable inside their second hire of the same
|
|
59
|
+
// agent, and made a standing grant unusable on any spend that named a deal.
|
|
60
|
+
// Whether a grant may be spent at all is the server's answer, and it turns on
|
|
61
|
+
// who GAVE the grant against who the agent is acting for — not on which of
|
|
62
|
+
// that party's deals is running. A list the server answered for this wake
|
|
63
|
+
// holds only grants it may spend, so there is nothing left to refuse here.
|
|
64
|
+
//
|
|
65
|
+
// The agreement still decides the ORDER below, which is a budget preference
|
|
66
|
+
// rather than a question of authority.
|
|
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
|
+
* This is a preference, never a fence. Every grant here is already one the
|
|
78
|
+
* server said this wake may spend, so picking a different one cannot reach
|
|
79
|
+
* another party's authority — it would only bill the wrong budget of the same
|
|
80
|
+
* party. That is why the mismatch is no longer a rejection above.
|
|
81
|
+
*/
|
|
82
|
+
export function selectPaymentGrant(grants, spend) {
|
|
83
|
+
const rejected = [];
|
|
84
|
+
const fitting = [];
|
|
85
|
+
for (const grant of grants) {
|
|
86
|
+
const reason = whyNot(grant, spend);
|
|
87
|
+
if (reason)
|
|
88
|
+
rejected.push({ grantId: grant.grantId, reason });
|
|
89
|
+
else
|
|
90
|
+
fitting.push(grant);
|
|
91
|
+
}
|
|
92
|
+
if (fitting.length === 0)
|
|
93
|
+
return { grant: null, rejected };
|
|
94
|
+
// Explicit both ways, because the deal is no longer a fence: until it was
|
|
95
|
+
// removed, a deal-bound grant simply never reached this list on a spend that
|
|
96
|
+
// named nothing, so "prefer the standing grant" happened by accident of the
|
|
97
|
+
// rejection. Now it has to be said.
|
|
98
|
+
const standing = fitting.find((g) => !g.agreementId);
|
|
99
|
+
const preferred = spend.agreementId
|
|
100
|
+
? (fitting.find((g) => g.agreementId === spend.agreementId) ?? standing)
|
|
101
|
+
: standing;
|
|
102
|
+
return { grant: preferred ?? fitting[0], rejected };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The sentence an agent gets when nothing fits.
|
|
106
|
+
*
|
|
107
|
+
* It names every grant considered and why each was not it, because "no payment
|
|
108
|
+
* grant" is a condition and an agent cannot act on a condition — it retries,
|
|
109
|
+
* and every retry is a full model call. Which ceiling was in the way is the
|
|
110
|
+
* thing that tells the agent whether to ask for a bigger grant, name the
|
|
111
|
+
* agreement, or stop.
|
|
112
|
+
*/
|
|
113
|
+
export function describeNoGrant(verb, spend, rejected) {
|
|
114
|
+
if (rejected.length === 0) {
|
|
115
|
+
return (`${verb}: you hold no active payment grant, so this spend cannot be ` +
|
|
116
|
+
`authorized. The wallet owner issues one; nothing you can do from here.`);
|
|
117
|
+
}
|
|
118
|
+
const lines = rejected.map((r) => ` - ${r.grantId} ${r.reason}`).join('\n');
|
|
119
|
+
return (`${verb}: none of your ${rejected.length} active payment grant(s) covers ` +
|
|
120
|
+
`this spend of ${spend.amount}.\n${lines}`);
|
|
121
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,3 +12,6 @@ export { parseErrorMessage, parseErrorCode, throwApiError, } from './shared/apiE
|
|
|
12
12
|
export { ApiError } from './types.js';
|
|
13
13
|
export type { Creds, Task, TaskState, PlanStep, PlanStepStatus, Agreement, AgreementParties, AgreementPartySide, EngagementKind, BroadcastAudience, EntryType, ContentType, MessageMetadata, MessageHandler, InboxDeliveryKind, InboxDeliveryRef, InboxChatNews, InboxProposalRef, InboxTaskRef, InboxRequestRef, InboxConnectionRequestRef, InboxHumanAttention, InboxEnvelope, InboxAckResult, InboxReadOptions, } from './types.js';
|
|
14
14
|
export type { ProposeTerms, ProposeDirectInput, ProposeBroadcastInput, ProposeAgreementData, ClaimOptions, } from './http/AgreementClient.js';
|
|
15
|
+
export { agreementLaneId, agreementIdFromLane, isAgreementLaneId, isAgreementId } from '@ziggs-ai/contracts';
|
|
16
|
+
export * from './shared/operatorKey.js';
|
|
17
|
+
export * from './utils/appUrls.js';
|
package/dist/index.js
CHANGED
|
@@ -13,3 +13,6 @@ export { RateLimitedError, isRateLimited, parseRetryAfterMs, pollSurfaceError, }
|
|
|
13
13
|
// half of them had drifted).
|
|
14
14
|
export { parseErrorMessage, parseErrorCode, throwApiError, } from './shared/apiError.js';
|
|
15
15
|
export { ApiError } from './types.js';
|
|
16
|
+
export { agreementLaneId, agreementIdFromLane, isAgreementLaneId, isAgreementId } from '@ziggs-ai/contracts';
|
|
17
|
+
export * from './shared/operatorKey.js';
|
|
18
|
+
export * from './utils/appUrls.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** JWT payload fields we read client-side (signature not verified — identity hint only). */
|
|
2
|
+
export interface OperatorKeyClaims {
|
|
3
|
+
type?: string;
|
|
4
|
+
keyId?: string;
|
|
5
|
+
ownerId?: string;
|
|
6
|
+
boundAgentId?: string | null;
|
|
7
|
+
/**
|
|
8
|
+
* How the key was authorized. MCP consent uses `mcp_oauth` or `device_code`;
|
|
9
|
+
* provisioning and other mint paths carry their own provenance.
|
|
10
|
+
*/
|
|
11
|
+
issuedVia?: string;
|
|
12
|
+
exp?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Decode operator JWT payload without verifying signature (boundAgentId). */
|
|
15
|
+
export declare function decodeOperatorKeyClaims(token: string): OperatorKeyClaims | null;
|
|
16
|
+
export declare function isOperatorKeyExpired(claims: OperatorKeyClaims | null): boolean;
|
|
17
|
+
/** The stamp the MCP OAuth authorization-code consent flow puts on its tokens. */
|
|
18
|
+
export declare const ISSUED_VIA_MCP_OAUTH = "mcp_oauth";
|
|
19
|
+
/** The stamp the MCP OAuth device-code flow puts on its tokens. */
|
|
20
|
+
export declare const ISSUED_VIA_DEVICE_CODE = "device_code";
|
|
21
|
+
/**
|
|
22
|
+
* Did a person board this credential through the connector directory?
|
|
23
|
+
*
|
|
24
|
+
* That is the question the tool surface turns on: a connected assistant
|
|
25
|
+
* (Claude, ChatGPT, Cursor) gets the listed, directory-reviewed surface, and
|
|
26
|
+
* anyone who configured this server themselves with an operator key gets the
|
|
27
|
+
* whole thing.
|
|
28
|
+
*
|
|
29
|
+
* Ask it as a predicate, never as `issuedVia === 'mcp_oauth'`. Two consent rails
|
|
30
|
+
* board an assistant through that door — the authorization-code flow and the
|
|
31
|
+
* device-code flow — and both want the same narrowed surface, but the server
|
|
32
|
+
* records them as the different acts they are. An equality test against one
|
|
33
|
+
* value serves the whole catalogue to every credential from the other one.
|
|
34
|
+
*
|
|
35
|
+
* Read off the unverified payload. On the remote endpoint the backend verified
|
|
36
|
+
* the signature before this ran; on stdio the key is the caller's own. Either
|
|
37
|
+
* way the payload is the one the backend signed. And what hangs on the answer
|
|
38
|
+
* is which tools are registered, not what a call may do: every call is still
|
|
39
|
+
* authorized by the backend. Provenance may decide what is OFFERED, never what
|
|
40
|
+
* is PERMITTED.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isDirectoryBoarded(claims: OperatorKeyClaims | null): boolean;
|
|
43
|
+
/** Routing hint only. Every request is still authenticated by the backend. */
|
|
44
|
+
export declare function isMcpOAuthDelegateSession(creds: {
|
|
45
|
+
operatorKey: string;
|
|
46
|
+
agentId: string;
|
|
47
|
+
}): boolean;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Decode operator JWT payload without verifying signature (boundAgentId). */
|
|
2
|
+
export function decodeOperatorKeyClaims(token) {
|
|
3
|
+
const trimmed = token.trim();
|
|
4
|
+
const parts = trimmed.split('.');
|
|
5
|
+
if (parts.length !== 3)
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const encoded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
9
|
+
const json = new TextDecoder().decode(Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)));
|
|
10
|
+
const payload = JSON.parse(json);
|
|
11
|
+
return {
|
|
12
|
+
type: payload.type,
|
|
13
|
+
keyId: payload.keyId,
|
|
14
|
+
ownerId: payload.ownerId,
|
|
15
|
+
boundAgentId: typeof payload.boundAgentId === 'string' ? payload.boundAgentId : null,
|
|
16
|
+
issuedVia: typeof payload.issuedVia === 'string' ? payload.issuedVia : undefined,
|
|
17
|
+
exp: payload.exp,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function isOperatorKeyExpired(claims) {
|
|
25
|
+
if (!claims?.exp)
|
|
26
|
+
return false;
|
|
27
|
+
return claims.exp * 1000 <= Date.now();
|
|
28
|
+
}
|
|
29
|
+
/** The stamp the MCP OAuth authorization-code consent flow puts on its tokens. */
|
|
30
|
+
export const ISSUED_VIA_MCP_OAUTH = 'mcp_oauth';
|
|
31
|
+
/** The stamp the MCP OAuth device-code flow puts on its tokens. */
|
|
32
|
+
export const ISSUED_VIA_DEVICE_CODE = 'device_code';
|
|
33
|
+
/**
|
|
34
|
+
* Did a person board this credential through the connector directory?
|
|
35
|
+
*
|
|
36
|
+
* That is the question the tool surface turns on: a connected assistant
|
|
37
|
+
* (Claude, ChatGPT, Cursor) gets the listed, directory-reviewed surface, and
|
|
38
|
+
* anyone who configured this server themselves with an operator key gets the
|
|
39
|
+
* whole thing.
|
|
40
|
+
*
|
|
41
|
+
* Ask it as a predicate, never as `issuedVia === 'mcp_oauth'`. Two consent rails
|
|
42
|
+
* board an assistant through that door — the authorization-code flow and the
|
|
43
|
+
* device-code flow — and both want the same narrowed surface, but the server
|
|
44
|
+
* records them as the different acts they are. An equality test against one
|
|
45
|
+
* value serves the whole catalogue to every credential from the other one.
|
|
46
|
+
*
|
|
47
|
+
* Read off the unverified payload. On the remote endpoint the backend verified
|
|
48
|
+
* the signature before this ran; on stdio the key is the caller's own. Either
|
|
49
|
+
* way the payload is the one the backend signed. And what hangs on the answer
|
|
50
|
+
* is which tools are registered, not what a call may do: every call is still
|
|
51
|
+
* authorized by the backend. Provenance may decide what is OFFERED, never what
|
|
52
|
+
* is PERMITTED.
|
|
53
|
+
*/
|
|
54
|
+
export function isDirectoryBoarded(claims) {
|
|
55
|
+
return (claims?.issuedVia === ISSUED_VIA_MCP_OAUTH ||
|
|
56
|
+
claims?.issuedVia === ISSUED_VIA_DEVICE_CODE);
|
|
57
|
+
}
|
|
58
|
+
/** Routing hint only. Every request is still authenticated by the backend. */
|
|
59
|
+
export function isMcpOAuthDelegateSession(creds) {
|
|
60
|
+
const claims = decodeOperatorKeyClaims(creds.operatorKey);
|
|
61
|
+
return (!!creds.agentId &&
|
|
62
|
+
claims?.boundAgentId === creds.agentId &&
|
|
63
|
+
isDirectoryBoarded(claims));
|
|
64
|
+
}
|