@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.
Files changed (46) hide show
  1. package/dist/capabilities/agreementVerbs.d.ts +9 -0
  2. package/dist/capabilities/agreementVerbs.js +29 -3
  3. package/dist/capabilities/agreements.js +2 -1
  4. package/dist/capabilities/artifacts.js +16 -6
  5. package/dist/capabilities/connections.d.ts +1 -1
  6. package/dist/capabilities/connections.js +82 -59
  7. package/dist/capabilities/grants.js +8 -2
  8. package/dist/capabilities/index.d.ts +1 -0
  9. package/dist/capabilities/index.js +1 -0
  10. package/dist/capabilities/links.js +12 -4
  11. package/dist/capabilities/listedFields.d.ts +17 -0
  12. package/dist/capabilities/listedFields.js +45 -0
  13. package/dist/capabilities/marketplace.js +7 -2
  14. package/dist/capabilities/payments.js +7 -2
  15. package/dist/capabilities/tasks.js +9 -3
  16. package/dist/http/AgreementClient.d.ts +29 -6
  17. package/dist/http/AgreementClient.js +2 -0
  18. package/dist/http/ArtifactsClient.d.ts +5 -16
  19. package/dist/http/ArtifactsClient.js +8 -5
  20. package/dist/http/ChatClient.d.ts +2 -6
  21. package/dist/http/ConnectionsClient.d.ts +15 -4
  22. package/dist/http/ConnectionsClient.js +53 -39
  23. package/dist/http/GrantsClient.d.ts +10 -1
  24. package/dist/http/GrantsClient.js +12 -2
  25. package/dist/http/IntroductionsClient.d.ts +11 -4
  26. package/dist/http/MarketplaceClient.d.ts +7 -0
  27. package/dist/http/MarketplaceClient.js +12 -3
  28. package/dist/http/OrgsClient.d.ts +0 -14
  29. package/dist/http/OrgsClient.js +2 -17
  30. package/dist/http/PaymentsClient.d.ts +47 -5
  31. package/dist/http/PaymentsClient.js +66 -22
  32. package/dist/http/TaskClient.js +1 -1
  33. package/dist/http/agreementFlows.js +9 -3
  34. package/dist/http/index.d.ts +1 -1
  35. package/dist/http/index.js +1 -1
  36. package/dist/http/paymentGrantSelection.d.ts +58 -0
  37. package/dist/http/paymentGrantSelection.js +121 -0
  38. package/dist/index.d.ts +3 -0
  39. package/dist/index.js +3 -0
  40. package/dist/shared/operatorKey.d.ts +47 -0
  41. package/dist/shared/operatorKey.js +64 -0
  42. package/dist/types.d.ts +6 -26
  43. package/dist/types.js +1 -19
  44. package/dist/utils/appUrls.d.ts +7 -0
  45. package/dist/utils/appUrls.js +38 -0
  46. 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
- constructor(operatorKey: string, agentId?: string, baseUrl?: string);
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
- /** Prefer a single active wallet grant the agent already holds. */
110
- private resolveAgentPaymentGrantId;
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
- constructor(operatorKey, agentId, baseUrl) {
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
- // resolve a standing/active grant when the caller omitted one
54
- // (Claude/MCP ensureForUser mints it). Explicit id still wins.
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
- // agent holds reserve the owner's wallet; require a grant.
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
- paymentGrantId = (await this.resolveAgentPaymentGrantId()) ?? undefined;
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
- /** Prefer a single active wallet grant the agent already holds. */
110
- async resolveAgentPaymentGrantId() {
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
- const grants = await this.listGrants();
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
- return null;
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);
@@ -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 AgentHost's three callers do worse than waste a wake: the needs gate
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
- if (!chatId)
42
- throw new Error('chatId is required on a direct proposal');
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
- chatId,
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);
@@ -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, isMcpOAuthDelegateAgentId, MCP_OAUTH_DELEGATE_AGENT_ID_PREFIX, } from './OrgsClient.js';
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';
@@ -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, isMcpOAuthDelegateAgentId, MCP_OAUTH_DELEGATE_AGENT_ID_PREFIX, } from './OrgsClient.js';
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
+ }