warpmetal 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,12 @@ treasury; the merchant tenant's active allowance controls sponsorship
124
124
  admission but is not charged actual gas. Payment authority depends on execution
125
125
  context:
126
126
 
127
+ WarpMetal can advertise any currently executable subset of those rails, from a
128
+ single alternative through all three. Agent Kit requires the sponsorship and
129
+ external-recipient extension bindings to match the advertised alternatives
130
+ exactly, then preserves the chosen authorization artifact byte-for-byte for
131
+ every pending or ambiguous retry.
132
+
127
133
  - When a human is actively chatting with the agent, show the exact live terms
128
134
  and ask for confirmation immediately before authorizing and submitting.
129
135
  - In an unattended run, a pre-funded dedicated wallet is standing spend
@@ -191,7 +197,7 @@ or escalation mechanism or stop with `funding_required`.
191
197
 
192
198
  Configure renewal only with explicit bounds. This example allows at most 12
193
199
  renewals, enforces a 30 USDC per-payment ceiling and a 360 USDC cumulative
194
- budget, and requests a verified notification recipient:
200
+ budget, and activates a human notification recipient after server authorization:
195
201
 
196
202
  ```sh
197
203
  warpmetal renewal configure \
@@ -207,12 +213,26 @@ warpmetal renewal configure \
207
213
  --json
208
214
  ```
209
215
 
210
- The recipient must follow the one-time verification link before lifecycle or
211
- refill mail is sent. If no verified notification email exists, `renewal
212
- configure` returns `email_required` before changing policy. Supply `--email`,
213
- or deliberately continue with `--without-email-notifications`; the latter does
214
- not enable signed refill-email workflows. A recurring unattended agent can then
215
- run:
216
+ No email verification is required: the saved owner credential or an SSH-derived
217
+ server token authorizes the change. WarpMetal queues a branded transactional
218
+ advisory identifying the server, why the address was added, and a link that
219
+ removes only that address. Up to five active addresses may be attached
220
+ to one server. If none exists, `renewal configure` returns `email_required`
221
+ before changing policy. Supply `--email`, or deliberately continue with
222
+ `--without-email-notifications`; the latter does not enable signed refill-email
223
+ workflows. A recurring unattended agent can then run:
224
+
225
+ ```sh
226
+ warpmetal notifications add --server <serverId> --email ops@example.com --json
227
+ warpmetal notifications list --server <serverId> --json
228
+ warpmetal notifications remove --server <serverId> --recipient <recipientId> --json
229
+ warpmetal notifications events --server <serverId> --events renewal.due,server.ready --json
230
+ warpmetal notifications disable --server <serverId> --json
231
+ ```
232
+
233
+ After provisioning, `warpmetal order status` includes the structured
234
+ `ask_human_for_notification_email` next action until a recipient is active or
235
+ the human explicitly opts out. Public CLI output contains only masked addresses.
216
236
 
217
237
  ```sh
218
238
  warpmetal renewal due --all --json
@@ -221,17 +241,17 @@ warpmetal renewal run --all-due --json
221
241
 
222
242
  Inside policy, the CLI returns the exact Agent Wallet authorization and submit
223
243
  argv. If balance is insufficient, `refillWorkflow` is returned only when the
224
- server has an active verified notification subscription. Run its argv with the
244
+ server has an active notification recipient. Run its argv with the
225
245
  returned `X402API_NOTIFICATION_URL` environment value. `x402api wallet
226
246
  notify-refill` signs an opaque subscription reference and wallet-produced
227
247
  balance fields; it cannot choose an email address. WarpMetal verifies the
228
- wallet signature and current on-chain balance before emailing the verified
229
- human the network, stablecoin, public wallet address, required minimum top-up,
248
+ wallet signature and current on-chain balance before emailing every active
249
+ human recipient the network, stablecoin, public wallet address, required minimum top-up,
230
250
  and a locally generated QR encoding only that wallet address. The address is
231
251
  also repeated as copyable text. The human may transfer more than that minimum;
232
252
  the renewal policy—not the refill target—remains the spending authority.
233
253
 
234
- The agent never sends a partial x402 payment. If no verified refill path
254
+ The agent never sends a partial x402 payment. If no active recipient refill path
235
255
  exists, it reports `funding_required`. If a previous payment is pending or
236
256
  ambiguous, it reconciles the saved attempt and never signs a second payment.
237
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -173,7 +173,8 @@ use `paymentWorkflow.fundingWorkflow` to obtain the public address and balance,
173
173
  then show the address as both a QR code and copyable text. Only when
174
174
  `refillNotification.available` is true, set the returned
175
175
  `refillWorkflow.environment`, invoke its exact argv once, and stop until
176
- funding arrives. The signed refill intent resolves to a verified human contact;
176
+ funding arrives. The signed refill intent resolves server-side to the active
177
+ SSH-authorized human recipients;
177
178
  never add an email address to it. Never make a partial payment.
178
179
 
179
180
  After funding, prepare again, authorize exactly once, submit with WarpMetal,
@@ -188,6 +189,15 @@ Poll a prepared or paid order with:
188
189
  warpmetal order status --task <taskId> --wait --json
189
190
  ```
190
191
 
192
+ When the ready response contains
193
+ `nextAction.action: ask_human_for_notification_email`, ask the human whether
194
+ they want renewal and lifecycle notices. If yes, add the supplied address with
195
+ `warpmetal notifications add --server <serverId> --email <address> --json`.
196
+ The server credential is the authorization; there is no email verification
197
+ step. The address receives a branded advisory and can remove itself without
198
+ affecting other recipients. If the human declines, run the returned
199
+ `notifications disable` command so future status checks do not ask again.
200
+
191
201
  `manual_review` remains terminal for payment and mutation attempts. A later
192
202
  read-only status check may observe the backend's authoritative reconciliation;
193
203
  prepare a replacement only for the exact `retrySafe: true` result described
@@ -125,15 +125,25 @@ warpmetal renewal prepare --server <serverId> --json
125
125
  warpmetal renewal submit --server <serverId> \
126
126
  --payment-artifact <path> [--wait] --json
127
127
  warpmetal renewal run (--server <serverId> | --all-due) --json
128
- warpmetal notifications configure --server <serverId> --email <address> \
128
+ warpmetal notifications add --server <serverId> --email <address> \
129
129
  [--events <comma-separated-events>] --json
130
- warpmetal notifications status --server <serverId> --json
130
+ warpmetal notifications list --server <serverId> --json
131
+ warpmetal notifications remove --server <serverId> --recipient <recipientId> --json
132
+ warpmetal notifications events --server <serverId> \
133
+ --events <comma-separated-events> --json
134
+ warpmetal notifications disable --server <serverId> --json
131
135
  ```
132
136
 
133
- Without a verified notification email, renewal configuration asks for an email
134
- before mutating policy unless `--without-email-notifications` explicitly opts
135
- out. `renewal prepare` always returns safe funding address/balance argv and
136
- returns signed refill-email argv only for a verified active subscription.
137
+ After a server becomes ready, `order status` returns
138
+ `nextAction.action: ask_human_for_notification_email` when setup has not been
139
+ completed or dismissed. Ask the human for an optional address, then use
140
+ `notifications add`; the server credential authorizes immediate activation, so
141
+ there is no verification step. A branded advisory identifies the server and
142
+ provides recipient-scoped removal. Up to five active recipients are supported.
143
+ Renewal configuration asks for an email before mutating policy unless
144
+ `--without-email-notifications` explicitly opts out. `renewal prepare` always
145
+ returns safe funding address/balance argv and returns signed refill-email argv
146
+ only when at least one active recipient exists.
137
147
  `renewal run` is an agent-facing state machine, not a wallet-signing daemon.
138
148
  See [renewals.md](renewals.md).
139
149
 
@@ -27,9 +27,10 @@ warpmetal renewal configure \
27
27
  --json
28
28
  ```
29
29
 
30
- Without an active verified notification email, configuration asks for one
31
- before changing policy. `--email` queues verification; refill and expanded
32
- lifecycle notices remain disabled until the human opens the verification link.
30
+ Without an active notification recipient, configuration asks for one before
31
+ changing policy. `--email` immediately activates the address because the owner
32
+ or SSH-derived server credential authorizes the change. WarpMetal sends a
33
+ transactional advisory to the new address; it does not require verification.
33
34
  Use `--without-email-notifications` only as an explicit opt-out; no signed
34
35
  refill-email workflow is returned in that state. A refill target is a funding
35
36
  target, not spend permission, and cannot exceed the per-renewal cap. The human
@@ -75,8 +76,8 @@ paymentWorkflow.authorize.argv
75
76
  paymentWorkflow.submit.argv
76
77
  paymentWorkflow.fundingWorkflow
77
78
  refillNotification
78
- refillWorkflow.environment (verified email only)
79
- refillWorkflow.argv (verified email only)
79
+ refillWorkflow.environment (active recipient only)
80
+ refillWorkflow.argv (active recipient only)
80
81
  ```
81
82
 
82
83
  Invoke the authorize argv once. It writes an owner-only artifact. Then invoke
package/src/api.js CHANGED
@@ -208,19 +208,43 @@ export class WarpMetalClient {
208
208
  );
209
209
  }
210
210
 
211
- putNotifications(serverId, body, token) {
211
+ putNotifications(serverId, body, token, idempotencyKey) {
212
212
  return this.request(
213
213
  "PUT",
214
214
  `/servers/${encodeURIComponent(serverId)}/notifications`,
215
- { body, token },
215
+ { body, token, idempotencyKey },
216
+ );
217
+ }
218
+
219
+ addNotificationRecipient(serverId, body, token, idempotencyKey) {
220
+ return this.request(
221
+ "POST",
222
+ `/servers/${encodeURIComponent(serverId)}/notification-recipients`,
223
+ { body, token, idempotencyKey },
224
+ );
225
+ }
226
+
227
+ patchNotifications(serverId, body, token, idempotencyKey) {
228
+ return this.request(
229
+ "PATCH",
230
+ `/servers/${encodeURIComponent(serverId)}/notifications`,
231
+ { body, token, idempotencyKey },
216
232
  );
217
233
  }
218
234
 
219
- deleteNotifications(serverId, token) {
235
+ deleteNotifications(serverId, token, idempotencyKey) {
220
236
  return this.request(
221
237
  "DELETE",
222
238
  `/servers/${encodeURIComponent(serverId)}/notifications`,
223
- { token },
239
+ { token, idempotencyKey },
240
+ );
241
+ }
242
+
243
+ deleteNotificationRecipient(serverId, recipientId, token, idempotencyKey) {
244
+ return this.request(
245
+ "DELETE",
246
+ `/servers/${encodeURIComponent(serverId)}/notification-recipients/${encodeURIComponent(recipientId)}`,
247
+ { token, idempotencyKey },
224
248
  );
225
249
  }
226
250
 
package/src/cli.js CHANGED
@@ -87,8 +87,14 @@ Usage:
87
87
  [--email <address> | --without-email-notifications]
88
88
  warpmetal renewal status|prepare|submit|run ...
89
89
  warpmetal renewal due (--server <serverId> | --all)
90
- warpmetal notifications configure --server <serverId> --email <address>
91
- warpmetal notifications status --server <serverId>
90
+ warpmetal notifications add --server <serverId> --email <address>
91
+ [--events <comma-separated-events>] [--idempotency-key <key>]
92
+ warpmetal notifications list --server <serverId>
93
+ warpmetal notifications remove --server <serverId> --recipient <recipientId>
94
+ [--idempotency-key <key>]
95
+ warpmetal notifications events --server <serverId> --events <comma-separated-events>
96
+ [--idempotency-key <key>]
97
+ warpmetal notifications disable --server <serverId> [--idempotency-key <key>]
92
98
  warpmetal identity generate --hostname <name> [--ssh-key-name <name>]
93
99
  warpmetal identity list
94
100
  warpmetal server identity --server <serverId>
@@ -673,11 +679,40 @@ async function handleTaskStatus(client, store, options, context) {
673
679
  const result = booleanOption(options, "wait")
674
680
  ? await pollTask(client, taskId, token, timeout)
675
681
  : await client.getTask(taskId, token);
682
+ const output = { ...result.data };
683
+ let notificationMessage = "";
684
+ if (result.data.task.state === "ready" && result.data.task.serverId) {
685
+ try {
686
+ const notifications = (
687
+ await client.getNotifications(result.data.task.serverId, token)
688
+ ).data;
689
+ output.notifications = notifications;
690
+ if (notifications.setupRecommended) {
691
+ output.nextAction = {
692
+ action: "ask_human_for_notification_email",
693
+ optional: true,
694
+ reason:
695
+ "A human can receive renewal, wallet refill, and server lifecycle notifications.",
696
+ addCommand: `warpmetal notifications add --server ${result.data.task.serverId} --email <address> --json`,
697
+ skipCommand: `warpmetal notifications disable --server ${result.data.task.serverId} --json`,
698
+ };
699
+ notificationMessage =
700
+ `\nAsk the human whether they want renewal and lifecycle notifications. ` +
701
+ `If yes, run: warpmetal notifications add --server ${result.data.task.serverId} --email <address>`;
702
+ }
703
+ } catch (error) {
704
+ output.notificationSetup = {
705
+ action: "check_notifications",
706
+ unavailable: true,
707
+ reason: toErrorMessage(error),
708
+ };
709
+ }
710
+ }
676
711
  emit(
677
712
  context.stdout,
678
- result.data,
713
+ output,
679
714
  context.json,
680
- `${result.data.task.id}: ${result.data.task.state}${result.data.task.publicIp ? ` (${result.data.task.publicIp})` : ""}`,
715
+ `${result.data.task.id}: ${result.data.task.state}${result.data.task.publicIp ? ` (${result.data.task.publicIp})` : ""}${notificationMessage}`,
681
716
  );
682
717
  return result.data.task.state === "manual_review" ? 6 : 0;
683
718
  }
@@ -853,8 +888,9 @@ function activeNotificationSubscription(notifications) {
853
888
  const subscription = notifications?.subscription;
854
889
  return Boolean(
855
890
  notifications?.configured &&
856
- subscription?.verified &&
857
- !subscription?.disabled,
891
+ !subscription?.disabled &&
892
+ Array.isArray(subscription?.recipients) &&
893
+ subscription.recipients.length > 0,
858
894
  );
859
895
  }
860
896
 
@@ -863,14 +899,14 @@ function refillNotificationState(notifications, policy) {
863
899
  if (!notifications?.configured || !subscription || subscription.disabled) {
864
900
  return {
865
901
  available: false,
866
- reason: "verified_email_required",
902
+ reason: "active_notification_recipient_required",
867
903
  subscription: subscription || null,
868
904
  };
869
905
  }
870
- if (!subscription.verified) {
906
+ if (!Array.isArray(subscription.recipients) || subscription.recipients.length === 0) {
871
907
  return {
872
908
  available: false,
873
- reason: "email_verification_required",
909
+ reason: "active_notification_recipient_required",
874
910
  subscription,
875
911
  };
876
912
  }
@@ -949,20 +985,10 @@ async function handleRenewalConfigure(client, store, options, context) {
949
985
  !withoutEmailNotifications &&
950
986
  !activeNotificationSubscription(notifications)
951
987
  ) {
952
- const verificationPending = Boolean(
953
- notifications.configured &&
954
- notifications.subscription &&
955
- !notifications.subscription.disabled,
956
- );
957
- const action = verificationPending
958
- ? "email_verification_required"
959
- : "email_required";
960
988
  const output = {
961
- action,
989
+ action: "email_required",
962
990
  serverId,
963
- reason: verificationPending
964
- ? "Verify the existing notification email or resend verification with --email."
965
- : "A verified email enables renewal and wallet-refill notifications.",
991
+ reason: "A human email enables renewal and wallet-refill notifications.",
966
992
  notifications,
967
993
  next: {
968
994
  configureEmail: `warpmetal renewal configure ... --email <address>`,
@@ -974,27 +1000,27 @@ async function handleRenewalConfigure(client, store, options, context) {
974
1000
  context.stdout,
975
1001
  output,
976
1002
  context.json,
977
- verificationPending
978
- ? `Verify the notification email for ${serverId}, or rerun with --email to resend verification. To opt out explicitly, rerun with --without-email-notifications.`
979
- : `An email is needed for renewal and wallet-refill notifications. Rerun with --email <address>, or opt out explicitly with --without-email-notifications.`,
1003
+ `Ask the human for an email to receive renewal and lifecycle notifications for ${serverId}, then rerun with --email <address>. To opt out explicitly, rerun with --without-email-notifications.`,
980
1004
  );
981
1005
  return 6;
982
1006
  }
983
1007
  if (email) {
984
1008
  notifications = (
985
- await client.putNotifications(serverId, { email }, token)
1009
+ await client.addNotificationRecipient(
1010
+ serverId,
1011
+ { email },
1012
+ token,
1013
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-add"),
1014
+ )
1015
+ ).data;
1016
+ } else if (withoutEmailNotifications) {
1017
+ notifications = (
1018
+ await client.deleteNotifications(
1019
+ serverId,
1020
+ token,
1021
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-disable"),
1022
+ )
986
1023
  ).data;
987
- } else if (
988
- withoutEmailNotifications &&
989
- notifications.configured &&
990
- notifications.subscription &&
991
- !notifications.subscription.disabled
992
- ) {
993
- await client.deleteNotifications(serverId, token);
994
- notifications = {
995
- ...notifications,
996
- subscription: { ...notifications.subscription, disabled: true },
997
- };
998
1024
  }
999
1025
  const result = await client.putRenewalPolicy(serverId, body, token);
1000
1026
  await store.saveRenewalPolicy(
@@ -1009,8 +1035,8 @@ async function handleRenewalConfigure(client, store, options, context) {
1009
1035
  status: withoutEmailNotifications
1010
1036
  ? "opted_out"
1011
1037
  : activeNotificationSubscription(notifications)
1012
- ? "verified"
1013
- : "verification_required",
1038
+ ? "active"
1039
+ : "recipient_required",
1014
1040
  };
1015
1041
  const output = {
1016
1042
  ...result.data,
@@ -1023,7 +1049,7 @@ async function handleRenewalConfigure(client, store, options, context) {
1023
1049
  context.stdout,
1024
1050
  output,
1025
1051
  context.json,
1026
- `Configured bounded renewal for ${serverId} with wallet ${wallet}.${email && !notificationState.refillAvailable ? " Check the email verification link." : withoutEmailNotifications ? " Email and wallet-refill notifications were explicitly skipped." : ""}`,
1052
+ `Configured bounded renewal for ${serverId} with wallet ${wallet}.${email ? " The notification recipient is active and its security advisory was queued." : withoutEmailNotifications ? " Email and wallet-refill notifications were explicitly skipped." : ""}`,
1027
1053
  );
1028
1054
  return 0;
1029
1055
  }
@@ -1257,9 +1283,9 @@ async function handleRenewalPrepare(client, store, options, context) {
1257
1283
  ? `\n${walletWorkflowInstructions(safe.paymentWorkflow)}. Show the returned payer address as both a QR code and copyable text, with the exact network, asset, and deficit.`
1258
1284
  : "";
1259
1285
  const refillInstructions = safe.refillWorkflow
1260
- ? `\nFor verified email refill, set ${Object.entries(safe.refillWorkflow.environment).map(([key, value]) => `${key}=${value}`).join(" ")} and run: ${shellCommand(safe.refillWorkflow.argv)}`
1286
+ ? `\nFor human refill notification, set ${Object.entries(safe.refillWorkflow.environment).map(([key, value]) => `${key}=${value}`).join(" ")} and run: ${shellCommand(safe.refillWorkflow.argv)}`
1261
1287
  : safe.paymentWorkflow
1262
- ? `\nVerified email refill is unavailable (${safe.refillNotification.reason}). Configure and verify notifications, or fund the displayed payer address directly.`
1288
+ ? `\nEmail refill is unavailable (${safe.refillNotification.reason}). Add a notification recipient, or fund the displayed payer address directly.`
1263
1289
  : "";
1264
1290
  const instructions = safe.paymentWorkflow
1265
1291
  ? `\nAuthorize: ${shellCommand(safe.paymentWorkflow.authorize.argv)}${fundingInstructions}${refillInstructions}\nSubmit: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
@@ -1464,38 +1490,113 @@ async function handleRenewalDue(client, store, options, context) {
1464
1490
  return due.length ? 8 : 0;
1465
1491
  }
1466
1492
 
1467
- async function handleNotificationsConfigure(client, store, options, context) {
1493
+ function notificationEvents(options, { required = false } = {}) {
1494
+ const eventValue = stringOption(options, "events", { required });
1495
+ if (!eventValue) return undefined;
1496
+ const events = eventValue.split(",").map((value) => value.trim());
1497
+ if (events.some((value) => !value)) {
1498
+ throw new CliError("--events must contain comma-separated event names.", {
1499
+ exitCode: 2,
1500
+ });
1501
+ }
1502
+ return events;
1503
+ }
1504
+
1505
+ async function handleNotificationsAdd(client, store, options, context) {
1468
1506
  const serverId = stringOption(options, "server", { required: true });
1469
1507
  const email = stringOption(options, "email", { required: true });
1470
- const eventValue = stringOption(options, "events");
1471
1508
  const body = { email };
1472
- if (eventValue) body.events = eventValue.split(",").map((value) => value.trim());
1509
+ const events = notificationEvents(options);
1510
+ if (events) body.events = events;
1473
1511
  const token = await requireServerToken(store, serverId, options, context.env);
1474
- const result = await client.putNotifications(serverId, body, token);
1512
+ const result = await client.addNotificationRecipient(
1513
+ serverId,
1514
+ body,
1515
+ token,
1516
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-add"),
1517
+ );
1475
1518
  emit(
1476
1519
  context.stdout,
1477
1520
  result.data,
1478
1521
  context.json,
1479
- `Verification email queued for ${result.data.subscription.email}.`,
1522
+ result.data.created
1523
+ ? `${result.data.recipient.email} is active for ${serverId}; WarpMetal queued a security advisory to that address.`
1524
+ : `${result.data.recipient.email} was already active for ${serverId}.`,
1480
1525
  );
1481
1526
  return 0;
1482
1527
  }
1483
1528
 
1484
- async function handleNotificationsStatus(client, store, options, context) {
1529
+ async function handleNotificationsList(client, store, options, context) {
1485
1530
  const serverId = stringOption(options, "server", { required: true });
1486
1531
  const token = await requireServerToken(store, serverId, options, context.env);
1487
1532
  const result = await client.getNotifications(serverId, token);
1533
+ const count = result.data.subscription?.recipients?.length || 0;
1488
1534
  emit(
1489
1535
  context.stdout,
1490
1536
  result.data,
1491
1537
  context.json,
1492
1538
  result.data.configured
1493
- ? `${serverId}: notifications ${result.data.subscription.verified ? "verified" : "awaiting verification"}`
1539
+ ? `${serverId}: ${count} active notification recipient${count === 1 ? "" : "s"}`
1494
1540
  : `${serverId}: notifications are not configured`,
1495
1541
  );
1496
1542
  return 0;
1497
1543
  }
1498
1544
 
1545
+ async function handleNotificationsRemove(client, store, options, context) {
1546
+ const serverId = stringOption(options, "server", { required: true });
1547
+ const recipientId = stringOption(options, "recipient", { required: true });
1548
+ const token = await requireServerToken(store, serverId, options, context.env);
1549
+ const result = await client.deleteNotificationRecipient(
1550
+ serverId,
1551
+ recipientId,
1552
+ token,
1553
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-remove"),
1554
+ );
1555
+ emit(
1556
+ context.stdout,
1557
+ result.data,
1558
+ context.json,
1559
+ `Removed ${recipientId} from ${serverId} notifications.`,
1560
+ );
1561
+ return 0;
1562
+ }
1563
+
1564
+ async function handleNotificationsEvents(client, store, options, context) {
1565
+ const serverId = stringOption(options, "server", { required: true });
1566
+ const events = notificationEvents(options, { required: true });
1567
+ const token = await requireServerToken(store, serverId, options, context.env);
1568
+ const result = await client.patchNotifications(
1569
+ serverId,
1570
+ { events },
1571
+ token,
1572
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-events"),
1573
+ );
1574
+ emit(
1575
+ context.stdout,
1576
+ result.data,
1577
+ context.json,
1578
+ `Updated notification events for ${serverId}.`,
1579
+ );
1580
+ return 0;
1581
+ }
1582
+
1583
+ async function handleNotificationsDisable(client, store, options, context) {
1584
+ const serverId = stringOption(options, "server", { required: true });
1585
+ const token = await requireServerToken(store, serverId, options, context.env);
1586
+ const result = await client.deleteNotifications(
1587
+ serverId,
1588
+ token,
1589
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-disable"),
1590
+ );
1591
+ emit(
1592
+ context.stdout,
1593
+ result.data,
1594
+ context.json,
1595
+ `Disabled lifecycle notifications for ${serverId}.`,
1596
+ );
1597
+ return 0;
1598
+ }
1599
+
1499
1600
  async function loginServer(client, store, serverId, identity) {
1500
1601
  const challenge = (await client.issueSshChallenge(serverId)).data;
1501
1602
  const signature = await signSshChallenge(challenge.payload, identity);
@@ -2553,6 +2654,7 @@ async function dispatch(positionals, options, passthrough, context) {
2553
2654
  "email",
2554
2655
  "without-email-notifications",
2555
2656
  "disabled",
2657
+ "idempotency-key",
2556
2658
  ]);
2557
2659
  return handleRenewalConfigure(client, store, options, context);
2558
2660
  case "renewal status":
@@ -2597,6 +2699,7 @@ async function dispatch(positionals, options, passthrough, context) {
2597
2699
  "request-envelope-out",
2598
2700
  ]);
2599
2701
  return handleRenewalRun(client, store, options, context);
2702
+ case "notifications add":
2600
2703
  case "notifications configure":
2601
2704
  rejectUnknownOptions(options, [
2602
2705
  ...COMMON_OPTIONS,
@@ -2604,15 +2707,43 @@ async function dispatch(positionals, options, passthrough, context) {
2604
2707
  "token-file",
2605
2708
  "email",
2606
2709
  "events",
2710
+ "idempotency-key",
2607
2711
  ]);
2608
- return handleNotificationsConfigure(client, store, options, context);
2712
+ return handleNotificationsAdd(client, store, options, context);
2713
+ case "notifications list":
2609
2714
  case "notifications status":
2610
2715
  rejectUnknownOptions(options, [
2611
2716
  ...COMMON_OPTIONS,
2612
2717
  "server",
2613
2718
  "token-file",
2614
2719
  ]);
2615
- return handleNotificationsStatus(client, store, options, context);
2720
+ return handleNotificationsList(client, store, options, context);
2721
+ case "notifications remove":
2722
+ rejectUnknownOptions(options, [
2723
+ ...COMMON_OPTIONS,
2724
+ "server",
2725
+ "token-file",
2726
+ "recipient",
2727
+ "idempotency-key",
2728
+ ]);
2729
+ return handleNotificationsRemove(client, store, options, context);
2730
+ case "notifications events":
2731
+ rejectUnknownOptions(options, [
2732
+ ...COMMON_OPTIONS,
2733
+ "server",
2734
+ "token-file",
2735
+ "events",
2736
+ "idempotency-key",
2737
+ ]);
2738
+ return handleNotificationsEvents(client, store, options, context);
2739
+ case "notifications disable":
2740
+ rejectUnknownOptions(options, [
2741
+ ...COMMON_OPTIONS,
2742
+ "server",
2743
+ "token-file",
2744
+ "idempotency-key",
2745
+ ]);
2746
+ return handleNotificationsDisable(client, store, options, context);
2616
2747
  case "server login":
2617
2748
  rejectUnknownOptions(options, [...COMMON_OPTIONS, "server", "identity"]);
2618
2749
  return handleServerLogin(client, store, options, context);
package/src/payment.js CHANGED
@@ -338,6 +338,47 @@ function externalRecipientBinds(requirement, extensions) {
338
338
  return matches.length === 1;
339
339
  }
340
340
 
341
+ function exactExtensionBindings(accepts, sponsorship, extensions) {
342
+ if (!sponsorship) return;
343
+ const sponsoredAccepts = new Set(
344
+ accepts.map(
345
+ (requirement) =>
346
+ `${requirement.network}|${requirement.asset.toLowerCase()}|${requirement.extra.payloadProfile}`,
347
+ ),
348
+ );
349
+ const sponsorshipBindings = new Set(
350
+ sponsorship.requirements.map(
351
+ (binding) =>
352
+ `${binding.network}|${binding.asset.toLowerCase()}|${binding.payloadProfile}`,
353
+ ),
354
+ );
355
+ const recipients = extensions["com.k1hub.external-recipient"]?.info?.recipients;
356
+ const acceptedRecipients = new Set(
357
+ accepts.map(
358
+ (requirement) =>
359
+ `${requirement.network}|${requirement.asset}|${requirement.payTo}`,
360
+ ),
361
+ );
362
+ const recipientBindings = new Set(
363
+ Array.isArray(recipients)
364
+ ? recipients.map(
365
+ (recipient) =>
366
+ `${recipient.network}|${recipient.asset}|${recipient.payTo}`,
367
+ )
368
+ : [],
369
+ );
370
+ if (
371
+ sponsoredAccepts.size !== sponsorshipBindings.size ||
372
+ [...sponsoredAccepts].some((identity) => !sponsorshipBindings.has(identity)) ||
373
+ acceptedRecipients.size !== recipientBindings.size ||
374
+ [...acceptedRecipients].some((identity) => !recipientBindings.has(identity))
375
+ ) {
376
+ throw invalid(
377
+ "The x402 sponsorship and recipient bindings do not exactly match PAYMENT-REQUIRED.",
378
+ );
379
+ }
380
+ }
381
+
341
382
  function agentWalletSupports(requirement, sponsorship, extensions) {
342
383
  const profile = requirement.extra.payloadProfile;
343
384
  const baseExtra = Object.keys(requirement.extra).sort().join(",");
@@ -397,6 +438,7 @@ export function parsePaymentRequired(value, expectedUrl) {
397
438
  "The x402 challenge extension",
398
439
  );
399
440
  const sponsorship = extensions[GAS_SPONSORSHIP_EXTENSION]?.info;
441
+ exactExtensionBindings(accepts, sponsorship, extensions);
400
442
  return {
401
443
  challenge: parsed,
402
444
  paymentRequiredDigest: digestJson(parsed),