@agentwonderland/mcp 0.1.50 → 0.1.52

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 (39) hide show
  1. package/dist/core/__tests__/api-client.test.js +6 -2
  2. package/dist/core/__tests__/payments.test.js +23 -2
  3. package/dist/core/__tests__/principal.test.js +25 -0
  4. package/dist/core/api-client.js +4 -2
  5. package/dist/core/config.d.ts +22 -0
  6. package/dist/core/config.js +20 -0
  7. package/dist/core/link-cli.js +97 -25
  8. package/dist/core/payments.js +20 -6
  9. package/dist/core/principal.d.ts +1 -0
  10. package/dist/core/principal.js +49 -1
  11. package/dist/core/version.d.ts +1 -1
  12. package/dist/core/version.js +1 -1
  13. package/dist/index.js +3 -0
  14. package/dist/tools/__tests__/rebates.test.d.ts +1 -0
  15. package/dist/tools/__tests__/rebates.test.js +72 -0
  16. package/dist/tools/__tests__/wallet.test.js +4 -0
  17. package/dist/tools/index.d.ts +1 -0
  18. package/dist/tools/index.js +1 -0
  19. package/dist/tools/observability.d.ts +2 -0
  20. package/dist/tools/observability.js +20 -0
  21. package/dist/tools/rebates.d.ts +2 -0
  22. package/dist/tools/rebates.js +51 -0
  23. package/dist/tools/wallet.js +11 -5
  24. package/package.json +1 -1
  25. package/src/core/__tests__/api-client.test.ts +8 -1
  26. package/src/core/__tests__/payments.test.ts +36 -2
  27. package/src/core/__tests__/principal.test.ts +31 -0
  28. package/src/core/api-client.ts +4 -2
  29. package/src/core/config.ts +46 -0
  30. package/src/core/link-cli.ts +114 -28
  31. package/src/core/payments.ts +21 -6
  32. package/src/core/principal.ts +54 -1
  33. package/src/core/version.ts +1 -1
  34. package/src/index.ts +3 -0
  35. package/src/tools/__tests__/rebates.test.ts +91 -0
  36. package/src/tools/__tests__/wallet.test.ts +10 -0
  37. package/src/tools/index.ts +1 -0
  38. package/src/tools/rebates.ts +102 -0
  39. package/src/tools/wallet.ts +11 -4
@@ -5,6 +5,7 @@ const state = vi.hoisted(() => ({
5
5
  card: null,
6
6
  link: null,
7
7
  pendingLinkSetup: null,
8
+ linkCooldown: null,
8
9
  pendingCardSetupToken: null,
9
10
  spendPolicies: {},
10
11
  defaultPaymentMethod: undefined,
@@ -53,6 +54,7 @@ vi.mock("../../core/config.js", () => ({
53
54
  },
54
55
  getCardConfig: () => state.card,
55
56
  getLinkConfig: () => state.link,
57
+ getLinkCooldown: () => state.linkCooldown,
56
58
  getPendingLinkSetup: () => state.pendingLinkSetup,
57
59
  getPendingCardSetupToken: () => state.pendingCardSetupToken,
58
60
  getSpendPolicy: (walletId) => state.spendPolicies[walletId] ?? null,
@@ -122,6 +124,7 @@ vi.mock("../../core/ows-adapter.js", () => ({
122
124
  }));
123
125
  vi.mock("../../core/principal.js", () => ({
124
126
  ensureConsumerPrincipal: async () => state.consumerPrincipal,
127
+ ensureConsumerPrincipalForMethod: async () => state.consumerPrincipal,
125
128
  getConsumerPrincipal: async () => state.consumerPrincipal,
126
129
  }));
127
130
  function resetState() {
@@ -130,6 +133,7 @@ function resetState() {
130
133
  state.card = null;
131
134
  state.link = null;
132
135
  state.pendingLinkSetup = null;
136
+ state.linkCooldown = null;
133
137
  state.pendingCardSetupToken = null;
134
138
  state.spendPolicies = {};
135
139
  state.defaultPaymentMethod = undefined;
@@ -8,6 +8,7 @@ export { registerWalletTools } from "./wallet.js";
8
8
  export { registerFavoriteTools } from "./favorites.js";
9
9
  export { registerTipTools } from "./tip.js";
10
10
  export { registerPassTools } from "./passes.js";
11
+ export { registerRebateTools } from "./rebates.js";
11
12
  export { registerUploadTools } from "./upload.js";
12
13
  export { registerProbeTools } from "./probe.js";
13
14
  export { registerProviderTools } from "./providers.js";
@@ -8,6 +8,7 @@ export { registerWalletTools } from "./wallet.js";
8
8
  export { registerFavoriteTools } from "./favorites.js";
9
9
  export { registerTipTools } from "./tip.js";
10
10
  export { registerPassTools } from "./passes.js";
11
+ export { registerRebateTools } from "./rebates.js";
11
12
  export { registerUploadTools } from "./upload.js";
12
13
  export { registerProbeTools } from "./probe.js";
13
14
  export { registerProviderTools } from "./providers.js";
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerObservabilityTools(server: McpServer): void;
@@ -0,0 +1,20 @@
1
+ import { apiPost } from "../core/api-client.js";
2
+ function text(t) {
3
+ return { content: [{ type: "text", text: t }] };
4
+ }
5
+ export function registerObservabilityTools(server) {
6
+ server.tool("open_observability_dashboard", "Generate a secure one-click sign-in URL for the Agent Wonderland web observability dashboard. The dashboard shows your agent runs, spend, rebates, and recent activity.", {}, async () => {
7
+ const result = await apiPost("/observability/link", {}, { ensureConsumerPrincipal: true });
8
+ const lines = [
9
+ "Your secure observability link is ready:",
10
+ result.url,
11
+ "",
12
+ `Expires: ${result.expires_at}`,
13
+ ];
14
+ if (result.consumer_principal) {
15
+ lines.push(`Consumer principal: ${result.consumer_principal}`);
16
+ }
17
+ lines.push("", "Open the link in your browser to view usage metrics, spend, rebates, and recent runs.");
18
+ return text(lines.join("\n"));
19
+ });
20
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerRebateTools(server: McpServer): void;
@@ -0,0 +1,51 @@
1
+ import { apiGet } from "../core/api-client.js";
2
+ function text(t) {
3
+ return { content: [{ type: "text", text: t }] };
4
+ }
5
+ function money(value) {
6
+ const numeric = Number(value);
7
+ return Number.isFinite(numeric) ? `$${numeric.toFixed(4)}` : `$${value}`;
8
+ }
9
+ function sourceLabel(value) {
10
+ return value.replace(/_/g, " ");
11
+ }
12
+ export function registerRebateTools(server) {
13
+ server.tool("rebate_status", "Show your Agent Wonderland consumer rebate balance, payout threshold, blocked rebates, and recent payout transactions.", {}, async () => {
14
+ const status = await apiGet("/rebates/status", { ensureConsumerPrincipal: true });
15
+ const threshold = Number(status.auto_payout_threshold_usd);
16
+ const pending = Number(status.totals.pending_usd);
17
+ const remaining = Math.max(0, threshold - pending);
18
+ const lines = [
19
+ "Rebate status",
20
+ `Consumer principal: ${status.consumer_principal}`,
21
+ `Base payout address: ${status.payout_address ?? "not configured"}`,
22
+ "",
23
+ `Lifetime earned: ${money(status.totals.lifetime_earned_usd)} across ${status.totals.total_count} rebate(s)`,
24
+ `Paid out: ${money(status.totals.paid_usd)} across ${status.totals.paid_count} rebate(s)`,
25
+ `Pending: ${money(status.totals.pending_usd)} across ${status.totals.pending_count} rebate(s)`,
26
+ `Blocked: ${money(status.totals.blocked_usd)} across ${status.totals.blocked_count} rebate(s)`,
27
+ ];
28
+ if (Number.isFinite(threshold) && threshold > 0) {
29
+ lines.push(pending >= threshold
30
+ ? `Auto payout: eligible for the next processor run (threshold ${money(threshold)})`
31
+ : `Auto payout: ${money(remaining)} until the ${money(threshold)} threshold`);
32
+ }
33
+ if (status.by_source.length > 0) {
34
+ lines.push("", "By source:");
35
+ for (const row of status.by_source) {
36
+ lines.push(` ${sourceLabel(row.source_type)} / ${row.status}: ${money(row.rebate_usd)} (${row.count})`);
37
+ }
38
+ }
39
+ if (status.recent_payouts.length > 0) {
40
+ lines.push("", "Recent payouts:");
41
+ for (const payout of status.recent_payouts.slice(0, 5)) {
42
+ const tx = payout.tx_hash ? ` tx=${payout.tx_hash}` : "";
43
+ lines.push(` ${payout.status}: ${money(payout.amount_usd)} (${payout.earning_count})${tx}`);
44
+ }
45
+ }
46
+ if (!status.payout_address) {
47
+ lines.push("", "No Base payout address is configured, so new rebates may be blocked.");
48
+ }
49
+ return text(lines.join("\n"));
50
+ });
51
+ }
@@ -1,12 +1,12 @@
1
1
  import { z } from "zod";
2
- import { getWallets, getCardConfig, getLinkConfig, getPendingLinkSetup, setPendingLinkSetup, setLinkConfig, setCardConfig, addWallet, getPendingCardSetupToken, getSpendPolicy, setDefaultPaymentMethod, setSpendPolicy, } from "../core/config.js";
2
+ import { getWallets, getCardConfig, getLinkConfig, getLinkCooldown, getPendingLinkSetup, setPendingLinkSetup, setLinkConfig, setCardConfig, addWallet, getPendingCardSetupToken, getSpendPolicy, setDefaultPaymentMethod, setSpendPolicy, } from "../core/config.js";
3
3
  import { getWalletAddress, isCardPaymentEnabled } from "../core/payments.js";
4
4
  import { fetchUsdcBalance } from "../core/balances.js";
5
5
  import { getSettings } from "../core/settings.js";
6
6
  import { getOrCreatePendingCardSetup, formatCardSetupBlocks, getCardCapabilities, pollCardSetup, } from "../core/card-setup.js";
7
7
  import { getLinkCliAuthStatus, listLinkPaymentMethods, openLinkPaymentMethodAdd, startLinkCliLogin, } from "../core/link-cli.js";
8
8
  import { isOwsAvailable, createOwsWallet, importKeyToOws, listOwsWallets, listOwsWalletsByChain, installOws, platformSupportsOws, } from "../core/ows-adapter.js";
9
- import { ensureConsumerPrincipal, getConsumerPrincipal } from "../core/principal.js";
9
+ import { ensureConsumerPrincipal, ensureConsumerPrincipalForMethod, getConsumerPrincipal } from "../core/principal.js";
10
10
  import { MCP_PACKAGE_VERSION } from "../core/version.js";
11
11
  function text(t) {
12
12
  return { content: [{ type: "text", text: t }] };
@@ -110,6 +110,12 @@ export function registerWalletTools(server) {
110
110
  lines.push(auth.authenticated
111
111
  ? " Link CLI: authenticated"
112
112
  : " Link CLI: not authenticated — run npx @stripe/link-cli auth login");
113
+ const cooldown = getLinkCooldown();
114
+ const blockedUntil = cooldown ? Date.parse(cooldown.blockedUntil) : NaN;
115
+ if (cooldown && Number.isFinite(blockedUntil) && blockedUntil > Date.now()) {
116
+ lines.push(` Link status: temporarily blocked by Stripe projected-spend cap until ${cooldown.blockedUntil}`);
117
+ lines.push(" Link note: reauthing or switching cards in the same Link account will not clear this cap.");
118
+ }
113
119
  }
114
120
  if (pendingCardSetupToken) {
115
121
  lines.push(" Card setup: pending confirmation");
@@ -215,7 +221,7 @@ export function registerWalletTools(server) {
215
221
  paymentMethodId: selected.id,
216
222
  label: name ?? selected.label,
217
223
  });
218
- const principal = await ensureConsumerPrincipal();
224
+ const principal = await ensureConsumerPrincipalForMethod("link");
219
225
  return text([
220
226
  "Link payment method connected.",
221
227
  ` Payment method: ${selected.label ?? selected.id}${selected.label ? ` (${selected.id})` : ""}`,
@@ -248,7 +254,7 @@ export function registerWalletTools(server) {
248
254
  paymentMethodId: method.id,
249
255
  label: method.label,
250
256
  });
251
- const principal = await ensureConsumerPrincipal();
257
+ const principal = await ensureConsumerPrincipalForMethod("link");
252
258
  return text([
253
259
  "Link payment method connected.",
254
260
  ` Payment method: ${method.id}${method.label ? ` (${method.label})` : ""}`,
@@ -287,7 +293,7 @@ export function registerWalletTools(server) {
287
293
  if (pendingToken) {
288
294
  const result = await pollCardSetup(pendingToken, 250);
289
295
  if (result) {
290
- const principal = await ensureConsumerPrincipal();
296
+ const principal = await ensureConsumerPrincipalForMethod("card");
291
297
  return text(`Connected! ${result.brand} ****${result.last4} is ready for payments.\n\n` +
292
298
  `Consumer principal: ${principal}`);
293
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentwonderland/mcp",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "type": "module",
5
5
  "description": "MCP server for the Agent Wonderland AI agent marketplace",
6
6
  "bin": {
@@ -1,10 +1,12 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { MCP_PACKAGE_VERSION } from "../version.js";
2
3
 
3
4
  const {
4
5
  mockGetApiUrl,
5
6
  mockGetApiKey,
6
7
  mockGetPaymentFetch,
7
8
  mockEnsureConsumerPrincipalForMethod,
9
+ mockEnsureBaseRebatePrincipal,
8
10
  mockGetConsumerPrincipalForMethod,
9
11
  mockGetBaseRebatePrincipal,
10
12
  mockPaymentFetch,
@@ -13,6 +15,7 @@ const {
13
15
  mockGetApiKey: vi.fn(),
14
16
  mockGetPaymentFetch: vi.fn(),
15
17
  mockEnsureConsumerPrincipalForMethod: vi.fn(),
18
+ mockEnsureBaseRebatePrincipal: vi.fn(),
16
19
  mockGetConsumerPrincipalForMethod: vi.fn(),
17
20
  mockGetBaseRebatePrincipal: vi.fn(),
18
21
  mockPaymentFetch: vi.fn(),
@@ -29,6 +32,7 @@ vi.mock("../payments.js", () => ({
29
32
 
30
33
  vi.mock("../principal.js", () => ({
31
34
  ensureConsumerPrincipal: vi.fn(),
35
+ ensureBaseRebatePrincipal: (...args: unknown[]) => mockEnsureBaseRebatePrincipal(...args),
32
36
  ensureConsumerPrincipalForMethod: (...args: unknown[]) => mockEnsureConsumerPrincipalForMethod(...args),
33
37
  getConsumerPrincipal: vi.fn(),
34
38
  getConsumerPrincipalForMethod: (...args: unknown[]) => mockGetConsumerPrincipalForMethod(...args),
@@ -50,6 +54,9 @@ describe("api-client headers", () => {
50
54
  mockGetBaseRebatePrincipal.mockResolvedValue(
51
55
  "did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
52
56
  );
57
+ mockEnsureBaseRebatePrincipal.mockResolvedValue(
58
+ "did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
59
+ );
53
60
  mockPaymentFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
54
61
  status: 200,
55
62
  headers: { "content-type": "application/json" },
@@ -72,7 +79,7 @@ describe("api-client headers", () => {
72
79
  "X-AW-Rebate-Principal":
73
80
  "did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
74
81
  "X-AW-Surface": "mcp",
75
- "X-AW-MCP-Version": "0.1.44",
82
+ "X-AW-MCP-Version": MCP_PACKAGE_VERSION,
76
83
  "X-AW-MCP-Tool": "run_agent",
77
84
  "X-AW-MCP-Action": "execute",
78
85
  }),
@@ -26,6 +26,7 @@ vi.mock("../config.js", () => ({
26
26
  getApiUrl: () => "http://api.test",
27
27
  getCardConfig: () => currentCard,
28
28
  getLinkConfig: () => currentLink,
29
+ getLinkCooldown: () => null,
29
30
  getConfig: () => ({ defaultPaymentMethod: currentDefaultPaymentMethod }),
30
31
  getDefaultWallet: () => currentDefaultWallet,
31
32
  getWallets: () => currentWallets,
@@ -166,17 +167,50 @@ describe("payment method initialization", () => {
166
167
 
167
168
  expect(mockCreateLinkSharedPaymentToken).toHaveBeenCalledWith(
168
169
  expect.objectContaining({
169
- amount: "2000",
170
+ amount: "25",
170
171
  currency: "usd",
171
172
  networkId: "profile_test",
172
173
  paymentMethodId: "csmrpd_link_123",
173
174
  }),
174
175
  );
175
176
  const linkTokenRequest = mockCreateLinkSharedPaymentToken.mock.calls[0]?.[0] as { context: string } | undefined;
176
- expect(linkTokenRequest?.context).toContain("up to USD 20.00");
177
+ expect(linkTokenRequest?.context).toContain("up to USD 0.25");
177
178
  expect(linkTokenRequest?.context).toContain("quoted at USD 0.25");
178
179
  });
179
180
 
181
+ it("uses an explicit Link approval override when configured", async () => {
182
+ process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS = "2000";
183
+ currentLink = {
184
+ paymentMethodId: "csmrpd_link_123",
185
+ label: "Visa ****4242",
186
+ };
187
+ currentDefaultPaymentMethod = "link";
188
+
189
+ const { getPaymentFetch } = await import("../payments.js");
190
+ await getPaymentFetch("link");
191
+
192
+ const stripeConfig = mockStripe.mock.calls[0]?.[0] as {
193
+ createToken: (params: {
194
+ amount: string;
195
+ currency: string;
196
+ expiresAt: number;
197
+ networkId: string;
198
+ }) => Promise<string>;
199
+ };
200
+ await stripeConfig.createToken({
201
+ amount: "25",
202
+ currency: "usd",
203
+ expiresAt: 1778290000,
204
+ networkId: "profile_test",
205
+ });
206
+
207
+ expect(mockCreateLinkSharedPaymentToken).toHaveBeenCalledWith(
208
+ expect.objectContaining({
209
+ amount: "2000",
210
+ }),
211
+ );
212
+ });
213
+
180
214
  it("allows a low env override while never approving below the quoted amount", async () => {
181
215
  process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS = "10";
182
216
  currentLink = {
@@ -137,6 +137,22 @@ describe("consumer principal helpers", () => {
137
137
  expect(state.addedWallets).toHaveLength(1);
138
138
  });
139
139
 
140
+ it("creates an EVM identity for Link payments when only a Solana wallet exists", async () => {
141
+ state.wallets = [
142
+ { id: "sol-wallet", keyType: "ows", owsWalletId: "ows-sol", chains: ["solana"], defaultChain: "solana" },
143
+ ];
144
+ state.addresses = {
145
+ "sol-wallet": "42W2HfLfveSm1T5et9WTLp2CZ2QXdF2EYCUvyJ2gPpxF",
146
+ };
147
+
148
+ const { ensureConsumerPrincipalForMethod } = await import("../principal.js");
149
+ const principal = await ensureConsumerPrincipalForMethod("link");
150
+
151
+ expect(principal).toMatch(/^did:pkh:eip155:8453:0x[a-f0-9]{40}$/);
152
+ expect(state.addedWallets).toHaveLength(1);
153
+ expect(state.addedWallets[0]?.chains).toEqual(["tempo", "base"]);
154
+ });
155
+
140
156
  it("returns the Base rebate principal when an EVM wallet is available", async () => {
141
157
  state.wallets = [
142
158
  { id: "aw-main", keyType: "ows", owsWalletId: "ows-main", chains: ["tempo", "base", "solana"], defaultChain: "tempo" },
@@ -152,4 +168,19 @@ describe("consumer principal helpers", () => {
152
168
 
153
169
  expect(principal).toBe("did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
154
170
  });
171
+
172
+ it("ensures a Base rebate principal even when the default wallet is Solana-only", async () => {
173
+ state.wallets = [
174
+ { id: "sol-wallet", keyType: "ows", owsWalletId: "ows-sol", chains: ["solana"], defaultChain: "solana" },
175
+ ];
176
+ state.addresses = {
177
+ "sol-wallet": "42W2HfLfveSm1T5et9WTLp2CZ2QXdF2EYCUvyJ2gPpxF",
178
+ };
179
+
180
+ const { ensureBaseRebatePrincipal } = await import("../principal.js");
181
+ const principal = await ensureBaseRebatePrincipal();
182
+
183
+ expect(principal).toMatch(/^did:pkh:eip155:8453:0x[a-f0-9]{40}$/);
184
+ expect(state.addedWallets).toHaveLength(1);
185
+ });
155
186
  });
@@ -2,7 +2,7 @@ import { getApiUrl, getApiKey } from "./config.js";
2
2
  import { getPaymentFetch } from "./payments.js";
3
3
  import {
4
4
  getBaseRebatePrincipal,
5
- ensureConsumerPrincipal,
5
+ ensureBaseRebatePrincipal,
6
6
  ensureConsumerPrincipalForMethod,
7
7
  getConsumerPrincipal,
8
8
  getConsumerPrincipalForMethod,
@@ -70,7 +70,9 @@ async function buildHeaders(path: string, method: string, options?: RequestOptio
70
70
  headers["X-AW-Consumer-Principal"] = principal;
71
71
  }
72
72
 
73
- const rebatePrincipal = await getBaseRebatePrincipal();
73
+ const rebatePrincipal = options?.ensureConsumerPrincipal
74
+ ? await ensureBaseRebatePrincipal()
75
+ : await getBaseRebatePrincipal();
74
76
  if (rebatePrincipal) {
75
77
  headers["X-AW-Rebate-Principal"] = rebatePrincipal;
76
78
  }
@@ -32,6 +32,24 @@ export interface PendingLinkSetup {
32
32
  createdAt: string;
33
33
  }
34
34
 
35
+ export interface PendingLinkSpendRequest {
36
+ id: string;
37
+ approvalUrl?: string;
38
+ amount: string;
39
+ currency: string;
40
+ context: string;
41
+ expiresAt: number;
42
+ networkId: string;
43
+ paymentMethodId: string;
44
+ createdAt: string;
45
+ }
46
+
47
+ export interface LinkCooldown {
48
+ reason: string;
49
+ createdAt: string;
50
+ blockedUntil: string;
51
+ }
52
+
35
53
  export interface SpendPolicy {
36
54
  maxPerTxUsd?: number;
37
55
  maxPerDayUsd?: number;
@@ -55,6 +73,8 @@ export interface Config {
55
73
  card: CardConfig | null;
56
74
  link: LinkConfig | null;
57
75
  pendingLinkSetup?: PendingLinkSetup | null;
76
+ pendingLinkSpendRequest?: PendingLinkSpendRequest | null;
77
+ linkCooldown?: LinkCooldown | null;
58
78
  pendingCardSetupToken?: string | null;
59
79
  favorites: string[];
60
80
  /** Require user confirmation before spending. Default: true. Set false for headless/automated use. */
@@ -113,6 +133,8 @@ interface LegacyConfig {
113
133
  card?: CardConfig | null;
114
134
  link?: LinkConfig | null;
115
135
  pendingLinkSetup?: PendingLinkSetup | null;
136
+ pendingLinkSpendRequest?: PendingLinkSpendRequest | null;
137
+ linkCooldown?: LinkCooldown | null;
116
138
  pendingCardSetupToken?: string | null;
117
139
  spendPolicies?: Record<string, SpendPolicy>;
118
140
  spendLedger?: SpendLedgerEntry[];
@@ -136,6 +158,8 @@ function migrateIfNeeded(raw: LegacyConfig): Config {
136
158
  card: raw.card ?? null,
137
159
  link: raw.link ?? null,
138
160
  pendingLinkSetup: raw.link ? null : (raw.pendingLinkSetup ?? null),
161
+ pendingLinkSpendRequest: raw.pendingLinkSpendRequest ?? null,
162
+ linkCooldown: raw.linkCooldown ?? null,
139
163
  pendingCardSetupToken: (r.pendingCardSetupToken as string | null | undefined) ?? null,
140
164
  favorites: r.favorites as string[] ?? [],
141
165
  confirmBeforeSpend: r.confirmBeforeSpend !== false,
@@ -214,6 +238,8 @@ function migrateIfNeeded(raw: LegacyConfig): Config {
214
238
  card,
215
239
  link: raw.link ?? null,
216
240
  pendingLinkSetup: raw.link ? null : (raw.pendingLinkSetup ?? null),
241
+ pendingLinkSpendRequest: raw.pendingLinkSpendRequest ?? null,
242
+ linkCooldown: raw.linkCooldown ?? null,
217
243
  pendingCardSetupToken: null,
218
244
  favorites: [],
219
245
  confirmBeforeSpend: true,
@@ -243,6 +269,8 @@ export function getConfig(): Config {
243
269
  card: null,
244
270
  link: null,
245
271
  pendingLinkSetup: null,
272
+ pendingLinkSpendRequest: null,
273
+ linkCooldown: null,
246
274
  pendingCardSetupToken: null,
247
275
  favorites: [],
248
276
  confirmBeforeSpend: true,
@@ -432,6 +460,22 @@ export function getPendingLinkSetup(): PendingLinkSetup | null {
432
460
  return getConfig().pendingLinkSetup ?? null;
433
461
  }
434
462
 
463
+ export function getPendingLinkSpendRequest(): PendingLinkSpendRequest | null {
464
+ return getConfig().pendingLinkSpendRequest ?? null;
465
+ }
466
+
467
+ export function setPendingLinkSpendRequest(pendingLinkSpendRequest: PendingLinkSpendRequest | null): void {
468
+ saveConfig({ pendingLinkSpendRequest });
469
+ }
470
+
471
+ export function getLinkCooldown(): LinkCooldown | null {
472
+ return getConfig().linkCooldown ?? null;
473
+ }
474
+
475
+ export function setLinkCooldown(linkCooldown: LinkCooldown | null): void {
476
+ saveConfig({ linkCooldown });
477
+ }
478
+
435
479
  /**
436
480
  * Save card configuration after setup.
437
481
  */
@@ -461,11 +505,13 @@ export function setLinkConfig(link: LinkConfig | null): void {
461
505
  link,
462
506
  defaultPaymentMethod: "link",
463
507
  pendingLinkSetup: null,
508
+ pendingLinkSpendRequest: null,
464
509
  });
465
510
  } else {
466
511
  saveConfig({
467
512
  link,
468
513
  pendingLinkSetup: null,
514
+ pendingLinkSpendRequest: null,
469
515
  defaultPaymentMethod: current.defaultPaymentMethod === "link"
470
516
  ? undefined
471
517
  : current.defaultPaymentMethod,
@@ -1,5 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import {
4
+ getPendingLinkSpendRequest,
5
+ setLinkCooldown,
6
+ setPendingLinkSpendRequest,
7
+ type PendingLinkSpendRequest,
8
+ } from "./config.js";
3
9
 
4
10
  const execFileAsync = promisify(execFile);
5
11
  const LINK_CLI_PACKAGE = "@stripe/link-cli";
@@ -116,6 +122,40 @@ function extractSpendRequestApproval(output: unknown): { id: string; approvalUrl
116
122
  return null;
117
123
  }
118
124
 
125
+ function isMatchingPendingSpendRequest(
126
+ pending: PendingLinkSpendRequest | null,
127
+ params: {
128
+ amount: string;
129
+ currency: string;
130
+ context: string;
131
+ expiresAt: number;
132
+ networkId: string;
133
+ paymentMethodId: string;
134
+ },
135
+ ): pending is PendingLinkSpendRequest {
136
+ if (!pending) return false;
137
+ if (pending.amount !== params.amount) return false;
138
+ if (pending.currency.toLowerCase() !== params.currency.toLowerCase()) return false;
139
+ if (pending.context !== params.context) return false;
140
+ if (pending.networkId !== params.networkId) return false;
141
+ if (pending.paymentMethodId !== params.paymentMethodId) return false;
142
+ if (pending.expiresAt <= Math.floor(Date.now() / 1000)) return false;
143
+ return true;
144
+ }
145
+
146
+ function isProjectedSpendCapError(message: string): boolean {
147
+ return /projected daily spend|projected spend|exceeds limit/i.test(message);
148
+ }
149
+
150
+ function recordLinkCooldown(reason: string): void {
151
+ const now = new Date();
152
+ setLinkCooldown({
153
+ reason,
154
+ createdAt: now.toISOString(),
155
+ blockedUntil: new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(),
156
+ });
157
+ }
158
+
119
159
  function normalizePaymentMethods(output: unknown): LinkCliPaymentMethod[] {
120
160
  const values = Array.isArray(output)
121
161
  ? output
@@ -215,6 +255,22 @@ export async function createLinkSharedPaymentToken(params: {
215
255
  networkId: string;
216
256
  paymentMethodId: string;
217
257
  }): Promise<string> {
258
+ const existing = getPendingLinkSpendRequest();
259
+ if (isMatchingPendingSpendRequest(existing, params)) {
260
+ try {
261
+ console.error(`Resuming pending Link approval: ${existing.id}`);
262
+ const spt = await retrieveSharedPaymentToken(existing.id, existing.approvalUrl);
263
+ setPendingLinkSpendRequest(null);
264
+ return spt;
265
+ } catch (err) {
266
+ const message = err instanceof Error ? err.message : String(err);
267
+ if (!/denied|expired|not found|without a shared payment token|POLLING_TIMEOUT/i.test(message)) {
268
+ throw err;
269
+ }
270
+ setPendingLinkSpendRequest(null);
271
+ }
272
+ }
273
+
218
274
  const args = [
219
275
  "spend-request",
220
276
  "create",
@@ -242,6 +298,18 @@ export async function createLinkSharedPaymentToken(params: {
242
298
  output = await runLinkCli(args);
243
299
  } catch (err) {
244
300
  const message = err instanceof Error ? err.message : String(err);
301
+ if (isProjectedSpendCapError(message)) {
302
+ recordLinkCooldown(message);
303
+ throw new Error(
304
+ [
305
+ "Link is temporarily blocked by Stripe's projected-spend cap.",
306
+ "Reauthing Link or switching cards in the same Link account will not fix this.",
307
+ "Use USDC for now, wait for the rolling Link window to clear, or ask Stripe to raise/clear the merchant projected-spend cap.",
308
+ "",
309
+ message,
310
+ ].join("\n"),
311
+ );
312
+ }
245
313
  if (/invalid network_id|could not retrieve merchant information/i.test(message)) {
246
314
  throw new Error(
247
315
  [
@@ -256,45 +324,63 @@ export async function createLinkSharedPaymentToken(params: {
256
324
  }
257
325
  const spt = extractSharedPaymentToken(output);
258
326
  if (spt) {
327
+ setPendingLinkSpendRequest(null);
259
328
  return spt;
260
329
  }
261
330
 
262
331
  const approval = extractSpendRequestApproval(output);
263
332
  if (approval?.id && approval.status === "pending_approval") {
333
+ setPendingLinkSpendRequest({
334
+ id: approval.id,
335
+ approvalUrl: approval.approvalUrl,
336
+ amount: params.amount,
337
+ currency: params.currency,
338
+ context: params.context,
339
+ expiresAt: params.expiresAt,
340
+ networkId: params.networkId,
341
+ paymentMethodId: params.paymentMethodId,
342
+ createdAt: new Date().toISOString(),
343
+ });
264
344
  if (approval.approvalUrl) {
265
345
  console.error(`Link approval required: ${approval.approvalUrl}`);
266
346
  }
267
- let retrieved = await runLinkCli([
268
- "spend-request",
269
- "retrieve",
270
- approval.id,
271
- "--interval",
272
- "2",
273
- "--max-attempts",
274
- "150",
275
- ]);
276
- let retrievedSpt = extractSharedPaymentToken(retrieved);
277
- for (let attempt = 0; !retrievedSpt && attempt < 30; attempt += 1) {
278
- await sleep(2_000);
279
- retrieved = await runLinkCli([
280
- "spend-request",
281
- "retrieve",
282
- approval.id,
283
- ]);
284
- retrievedSpt = extractSharedPaymentToken(retrieved);
285
- }
286
- if (retrievedSpt) {
287
- return retrievedSpt;
288
- }
289
- throw new Error(
290
- [
291
- "Link spend request finished without a shared payment token.",
292
- approval.approvalUrl ? `Approval URL: ${approval.approvalUrl}` : undefined,
293
- ].filter(Boolean).join("\n"),
294
- );
347
+ const retrievedSpt = await retrieveSharedPaymentToken(approval.id, approval.approvalUrl);
348
+ setPendingLinkSpendRequest(null);
349
+ return retrievedSpt;
295
350
  }
296
351
 
297
352
  {
298
353
  throw new Error("Link spend request completed without a shared payment token in the CLI response.");
299
354
  }
300
355
  }
356
+
357
+ async function retrieveSharedPaymentToken(spendRequestId: string, approvalUrl?: string): Promise<string> {
358
+ let retrieved = await runLinkCli([
359
+ "spend-request",
360
+ "retrieve",
361
+ spendRequestId,
362
+ "--interval",
363
+ "2",
364
+ "--max-attempts",
365
+ "150",
366
+ ]);
367
+ let retrievedSpt = extractSharedPaymentToken(retrieved);
368
+ for (let attempt = 0; !retrievedSpt && attempt < 30; attempt += 1) {
369
+ await sleep(2_000);
370
+ retrieved = await runLinkCli([
371
+ "spend-request",
372
+ "retrieve",
373
+ spendRequestId,
374
+ ]);
375
+ retrievedSpt = extractSharedPaymentToken(retrieved);
376
+ }
377
+ if (retrievedSpt) {
378
+ return retrievedSpt;
379
+ }
380
+ throw new Error(
381
+ [
382
+ "Link spend request finished without a shared payment token.",
383
+ approvalUrl ? `Approval URL: ${approvalUrl}` : undefined,
384
+ ].filter(Boolean).join("\n"),
385
+ );
386
+ }