warpmetal 0.7.9 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ published wallet CLI with the exact version WarpMetal reports:
34
34
  npm install --global warpmetal
35
35
  warpmetal --help
36
36
 
37
- npm install --global @x402api/agent-wallet-cli@0.2.8
37
+ npm install --global @x402api/agent-wallet-cli@0.2.9
38
38
  x402api help --json
39
39
  ```
40
40
 
@@ -58,7 +58,7 @@ integration.
58
58
 
59
59
  The repository also contains a skills-only WarpMetal plugin for the public
60
60
  Plugins Directory shared by Codex and ChatGPT. The plugin remains a separate
61
- artifact from the npm CLI and requires `warpmetal` CLI version 0.7.8 or newer.
61
+ artifact from the npm CLI and requires `warpmetal` CLI version 0.8.1 or newer.
62
62
 
63
63
  To test the repository marketplace after the plugin lands on `main`:
64
64
 
@@ -130,6 +130,12 @@ external-recipient extension bindings to match the advertised alternatives
130
130
  exactly, then preserves the chosen authorization artifact byte-for-byte for
131
131
  every pending or ambiguous retry.
132
132
 
133
+ Submission output includes the server-validated `paymentId`, `confirmed`, and
134
+ `finalized` fields when WarpMetal supplies lifecycle evidence. Retry only while
135
+ confirmation is pending. As soon as `confirmed` is true, stop all payment
136
+ submission—even when `finalized` is false—and let WarpMetal continue signed-
137
+ receipt finality while provisioning or renewal proceeds.
138
+
133
139
  - When a human is actively chatting with the agent, show the exact live terms
134
140
  and ask for confirmation immediately before authorizing and submitting.
135
141
  - In an unattended run, a pre-funded dedicated wallet is standing spend
@@ -197,7 +203,7 @@ or escalation mechanism or stop with `funding_required`.
197
203
 
198
204
  Configure renewal only with explicit bounds. This example allows at most 12
199
205
  renewals, enforces a 30 USDC per-payment ceiling and a 360 USDC cumulative
200
- budget, and requests a verified notification recipient:
206
+ budget, and activates a human notification recipient after server authorization:
201
207
 
202
208
  ```sh
203
209
  warpmetal renewal configure \
@@ -213,12 +219,26 @@ warpmetal renewal configure \
213
219
  --json
214
220
  ```
215
221
 
216
- The recipient must follow the one-time verification link before lifecycle or
217
- refill mail is sent. If no verified notification email exists, `renewal
218
- configure` returns `email_required` before changing policy. Supply `--email`,
219
- or deliberately continue with `--without-email-notifications`; the latter does
220
- not enable signed refill-email workflows. A recurring unattended agent can then
221
- run:
222
+ No email verification is required: the saved owner credential or an SSH-derived
223
+ server token authorizes the change. WarpMetal queues a branded transactional
224
+ advisory identifying the server, why the address was added, and a link that
225
+ removes only that address. Up to five active addresses may be attached
226
+ to one server. If none exists, `renewal configure` returns `email_required`
227
+ before changing policy. Supply `--email`, or deliberately continue with
228
+ `--without-email-notifications`; the latter does not enable signed refill-email
229
+ workflows. A recurring unattended agent can then run:
230
+
231
+ ```sh
232
+ warpmetal notifications add --server <serverId> --email ops@example.com --json
233
+ warpmetal notifications list --server <serverId> --json
234
+ warpmetal notifications remove --server <serverId> --recipient <recipientId> --json
235
+ warpmetal notifications events --server <serverId> --events renewal.due,server.ready --json
236
+ warpmetal notifications disable --server <serverId> --json
237
+ ```
238
+
239
+ After provisioning, `warpmetal order status` includes the structured
240
+ `ask_human_for_notification_email` next action until a recipient is active or
241
+ the human explicitly opts out. Public CLI output contains only masked addresses.
222
242
 
223
243
  ```sh
224
244
  warpmetal renewal due --all --json
@@ -227,17 +247,17 @@ warpmetal renewal run --all-due --json
227
247
 
228
248
  Inside policy, the CLI returns the exact Agent Wallet authorization and submit
229
249
  argv. If balance is insufficient, `refillWorkflow` is returned only when the
230
- server has an active verified notification subscription. Run its argv with the
250
+ server has an active notification recipient. Run its argv with the
231
251
  returned `X402API_NOTIFICATION_URL` environment value. `x402api wallet
232
252
  notify-refill` signs an opaque subscription reference and wallet-produced
233
253
  balance fields; it cannot choose an email address. WarpMetal verifies the
234
- wallet signature and current on-chain balance before emailing the verified
235
- human the network, stablecoin, public wallet address, required minimum top-up,
254
+ wallet signature and current on-chain balance before emailing every active
255
+ human recipient the network, stablecoin, public wallet address, required minimum top-up,
236
256
  and a locally generated QR encoding only that wallet address. The address is
237
257
  also repeated as copyable text. The human may transfer more than that minimum;
238
258
  the renewal policy—not the refill target—remains the spending authority.
239
259
 
240
- The agent never sends a partial x402 payment. If no verified refill path
260
+ The agent never sends a partial x402 payment. If no active recipient refill path
241
261
  exists, it reports `funding_required`. If a previous payment is pending or
242
262
  ambiguous, it reconciles the saved attempt and never signs a second payment.
243
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.7.9",
3
+ "version": "0.8.1",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@ with ad hoc HTTP commands.
11
11
 
12
12
  ## Start safely
13
13
 
14
- 1. Run `warpmetal --version` and require version `0.7.8` or newer for this
14
+ 1. Run `warpmetal --version` and require version `0.8.1` or newer for this
15
15
  plugin. If it is missing or older, explain the compatibility requirement,
16
16
  ask before installing or upgrading software, and use only the official npm
17
17
  package from `https://www.npmjs.com/package/warpmetal`.
@@ -173,11 +173,13 @@ 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,
180
- and confirm the returned `termEndsAt`. On `reconcile_pending` or
181
+ and require `confirmed: true` before accepting the returned `termEndsAt`.
182
+ Stop payment submission at confirmation even when `finalized` is false. On `reconcile_pending` or
181
183
  `manual_review`, do not create another authorization.
182
184
 
183
185
  ## Provision and manage
@@ -188,6 +190,15 @@ Poll a prepared or paid order with:
188
190
  warpmetal order status --task <taskId> --wait --json
189
191
  ```
190
192
 
193
+ When the ready response contains
194
+ `nextAction.action: ask_human_for_notification_email`, ask the human whether
195
+ they want renewal and lifecycle notices. If yes, add the supplied address with
196
+ `warpmetal notifications add --server <serverId> --email <address> --json`.
197
+ The server credential is the authorization; there is no email verification
198
+ step. The address receives a branded advisory and can remove itself without
199
+ affecting other recipients. If the human declines, run the returned
200
+ `notifications disable` command so future status checks do not ask again.
201
+
191
202
  `manual_review` remains terminal for payment and mutation attempts. A later
192
203
  read-only status check may observe the backend's authoritative reconciliation;
193
204
  prepare a replacement only for the exact `retrySafe: true` result described
@@ -72,7 +72,7 @@ retire that attempt and return a new `paymentAttemptId`. The CLI replaces the
72
72
  saved challenge and stale wallet-attempt metadata; use only the newly returned
73
73
  workflow.
74
74
 
75
- The current integration targets `@x402api/agent-wallet-cli@0.2.8`. A compatible
75
+ The current integration targets `@x402api/agent-wallet-cli@0.2.9`. A compatible
76
76
  live term is marked `agentWalletSupported: true` and must use the sponsored
77
77
  Base USDC or Solana USDC/USDT launch profile with buyer native fees disabled.
78
78
  For the current declaration, x402api pays actual gas from its platform treasury
@@ -83,6 +83,12 @@ term. Because the checkout is authenticated, do not substitute `x402api pay`,
83
83
  `payment submit`, or `payment reconcile` for the returned WarpMetal submission
84
84
  command.
85
85
 
86
+ `warpmetal checkout submit` and `warpmetal renewal submit` surface
87
+ `paymentId`, `confirmed`, and `finalized` from WarpMetal and persist those safe
88
+ lifecycle fields in private CLI state. `confirmed: true` is terminal for
89
+ payment submission and starts the business operation; `finalized: false` only
90
+ means signed-receipt reconciliation continues asynchronously.
91
+
86
92
  `--payment-artifact` accepts the owner-only JSON artifact produced by a
87
93
  compatible pinned x402api Agent Wallet release. WarpMetal validates its request
88
94
  and payment-requirement digests, resource, extensions, buyer payment identifier,
@@ -125,15 +131,25 @@ warpmetal renewal prepare --server <serverId> --json
125
131
  warpmetal renewal submit --server <serverId> \
126
132
  --payment-artifact <path> [--wait] --json
127
133
  warpmetal renewal run (--server <serverId> | --all-due) --json
128
- warpmetal notifications configure --server <serverId> --email <address> \
134
+ warpmetal notifications add --server <serverId> --email <address> \
129
135
  [--events <comma-separated-events>] --json
130
- warpmetal notifications status --server <serverId> --json
136
+ warpmetal notifications list --server <serverId> --json
137
+ warpmetal notifications remove --server <serverId> --recipient <recipientId> --json
138
+ warpmetal notifications events --server <serverId> \
139
+ --events <comma-separated-events> --json
140
+ warpmetal notifications disable --server <serverId> --json
131
141
  ```
132
142
 
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.
143
+ After a server becomes ready, `order status` returns
144
+ `nextAction.action: ask_human_for_notification_email` when setup has not been
145
+ completed or dismissed. Ask the human for an optional address, then use
146
+ `notifications add`; the server credential authorizes immediate activation, so
147
+ there is no verification step. A branded advisory identifies the server and
148
+ provides recipient-scoped removal. Up to five active recipients are supported.
149
+ Renewal configuration asks for an email before mutating policy unless
150
+ `--without-email-notifications` explicitly opts out. `renewal prepare` always
151
+ returns safe funding address/balance argv and returns signed refill-email argv
152
+ only when at least one active recipient exists.
137
153
  `renewal run` is an agent-facing state machine, not a wallet-signing daemon.
138
154
  See [renewals.md](renewals.md).
139
155
 
@@ -25,7 +25,7 @@ contract.
25
25
  WarpMetal runs on Node.js 20 or 22. The x402api Agent Wallet currently requires
26
26
  Node.js 22. Use the exact published package reported by
27
27
  `paymentWorkflow.signerPackage.spec`; the current contract is
28
- `@x402api/agent-wallet-cli@0.2.8`. Do not add it as a WarpMetal dependency,
28
+ `@x402api/agent-wallet-cli@0.2.9`. Do not add it as a WarpMetal dependency,
29
29
  install executable wallet code from an unpinned repository URL, or substitute
30
30
  a similarly named package.
31
31
 
@@ -39,7 +39,9 @@ skill automatically.
39
39
 
40
40
  1. Install the exact `paymentWorkflow.signerPackage.install.argv` under Node
41
41
  22. Run `command -v x402api` and the returned
42
- `paymentWorkflow.signerContract.probe.argv`. Require contract version 1.
42
+ `paymentWorkflow.signerContract.probe.argv`. Require contract version 1 and
43
+ every entry in `paymentWorkflow.signerContract.requiredCommands` to appear
44
+ in the probe's `commands` array.
43
45
  2. Run the exact `paymentWorkflow.walletWorkflow.setup.argv`. The idempotent
44
46
  `x402api wallet setup --json` command creates an owner-only managed unlock
45
47
  file inside the x402api home directory and never prints its generated
@@ -152,7 +154,7 @@ reservation or any buyer-funded, unsupported, or unbound alternative.
152
154
 
153
155
  x402api pays actual gas from its platform treasury. The merchant tenant's
154
156
  active sponsorship allowance controls admission, but actual gas is not a
155
- tenant debit. During the coordinated rollout, accept only the exact 0.2.8
157
+ tenant debit. During the coordinated rollout, accept only the exact 0.2.9
156
158
  platform-treasury declaration or the matched legacy tenant-credit declaration;
157
159
  never accept a mixed billing and final-charge policy.
158
160
 
@@ -193,9 +195,15 @@ current `termEndsAt` generation are different.
193
195
  wallet-attempt metadata when it saves the new challenge. Inspect the new
194
196
  terms and authorize only the new workflow; never reuse an expired envelope or
195
197
  artifact.
196
- - `payment_pending` or `payment_finalizing`: keep the same checkout bytes and
197
- artifact. Preserve the returned durable `paymentId`; `--wait` records it in
198
- private WarpMetal state and performs bounded retries with that exact authorization.
198
+ - `payment_pending`, or legacy `payment_finalizing`, without
199
+ `confirmed: true`: keep the same checkout bytes and artifact. Preserve the
200
+ returned durable `paymentId`; `--wait` records it in private WarpMetal state
201
+ and performs bounded retries with that exact authorization.
202
+ - `confirmed: true`: stop payment submission immediately, including when
203
+ `finalized` is false or a legacy pending-like status is present. WarpMetal
204
+ has admitted provisioning or renewal exactly once and will reconcile payment
205
+ detail plus the signed final receipt asynchronously. Never authorize a
206
+ replacement because receipt finality is pending.
199
207
  - Timeout or process restart after authorization: use the saved x402api attempt
200
208
  ID and artifact. Reconcile with WarpMetal and reuse its submit argv. Never
201
209
  authorize again merely because submission is unknown.
@@ -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
@@ -58,7 +59,8 @@ The CLI does not install a daemon. Treat actions as follows:
58
59
  - `refill_required`: follow the signed refill workflow and stop.
59
60
  - `reconcile_pending`: preserve the artifact and reconcile; never authorize again.
60
61
  - `manual_review`: stop all payment and mutation retries.
61
- - `renewed`: verify the new `termEndsAt` and stop successfully.
62
+ - `renewed` with `confirmed: true`: verify the new `termEndsAt` and stop
63
+ payment submission successfully, even when `finalized` is false.
62
64
 
63
65
  ## Authorize and submit renewal
64
66
 
@@ -75,8 +77,8 @@ paymentWorkflow.authorize.argv
75
77
  paymentWorkflow.submit.argv
76
78
  paymentWorkflow.fundingWorkflow
77
79
  refillNotification
78
- refillWorkflow.environment (verified email only)
79
- refillWorkflow.argv (verified email only)
80
+ refillWorkflow.environment (active recipient only)
81
+ refillWorkflow.argv (active recipient only)
80
82
  ```
81
83
 
82
84
  Invoke the authorize argv once. It writes an owner-only artifact. Then invoke
@@ -117,6 +117,9 @@ Confirm the plan, hostname, OS, and public key before preparing it.
117
117
  - For `payment_pending`, retry the exact checkout body and the exact same
118
118
  payment artifact. Preserve the returned `paymentId` as the durable x402api
119
119
  reconciliation key. Do not create a replacement payment.
120
+ - For `confirmed: true`, stop payment submission even when `finalized` is
121
+ false. Finality and reorg checks are read-only WarpMetal backend work; they
122
+ never authorize another payment.
120
123
  - A signed HTTP 402 rejects that signature. A `paymentId` can be absent;
121
124
  preserve the safe `errorCode`, `requestId`, and `replacementAllowed` result.
122
125
  Request a new live challenge and create the one permitted replacement only
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>
@@ -209,6 +215,42 @@ function suggestedDelay(result, fallback = 2) {
209
215
  );
210
216
  }
211
217
 
218
+ function paymentLifecycle(result) {
219
+ const confirmed = result.data?.confirmed;
220
+ const finalized = result.data?.finalized;
221
+ if (confirmed === undefined && finalized === undefined) {
222
+ return { confirmed: undefined, finalized: undefined };
223
+ }
224
+ if (
225
+ typeof confirmed !== "boolean" ||
226
+ typeof finalized !== "boolean" ||
227
+ (finalized && !confirmed)
228
+ ) {
229
+ throw new CliError("WarpMetal returned contradictory payment lifecycle evidence.", {
230
+ exitCode: 3,
231
+ });
232
+ }
233
+ if (confirmed && !result.data?.paymentId) {
234
+ throw new CliError("WarpMetal confirmed payment without a payment ID.", {
235
+ exitCode: 3,
236
+ });
237
+ }
238
+ if (["provisioning", "renewed"].includes(result.data?.status) && !confirmed) {
239
+ throw new CliError("WarpMetal returned a terminal payment status without confirmation.", {
240
+ exitCode: 3,
241
+ });
242
+ }
243
+ return { confirmed, finalized };
244
+ }
245
+
246
+ function paymentSubmissionPending(result, lifecycle) {
247
+ return (
248
+ result.status === 202 &&
249
+ lifecycle.confirmed !== true &&
250
+ ["payment_pending", "payment_finalizing"].includes(result.data?.status)
251
+ );
252
+ }
253
+
212
254
  async function readHeaderValueFile(path, label) {
213
255
  const value = (await readFile(resolve(path), "utf8")).trim();
214
256
  if (!value || value.includes("\n") || value.includes("\r")) {
@@ -673,11 +715,40 @@ async function handleTaskStatus(client, store, options, context) {
673
715
  const result = booleanOption(options, "wait")
674
716
  ? await pollTask(client, taskId, token, timeout)
675
717
  : await client.getTask(taskId, token);
718
+ const output = { ...result.data };
719
+ let notificationMessage = "";
720
+ if (result.data.task.state === "ready" && result.data.task.serverId) {
721
+ try {
722
+ const notifications = (
723
+ await client.getNotifications(result.data.task.serverId, token)
724
+ ).data;
725
+ output.notifications = notifications;
726
+ if (notifications.setupRecommended) {
727
+ output.nextAction = {
728
+ action: "ask_human_for_notification_email",
729
+ optional: true,
730
+ reason:
731
+ "A human can receive renewal, wallet refill, and server lifecycle notifications.",
732
+ addCommand: `warpmetal notifications add --server ${result.data.task.serverId} --email <address> --json`,
733
+ skipCommand: `warpmetal notifications disable --server ${result.data.task.serverId} --json`,
734
+ };
735
+ notificationMessage =
736
+ `\nAsk the human whether they want renewal and lifecycle notifications. ` +
737
+ `If yes, run: warpmetal notifications add --server ${result.data.task.serverId} --email <address>`;
738
+ }
739
+ } catch (error) {
740
+ output.notificationSetup = {
741
+ action: "check_notifications",
742
+ unavailable: true,
743
+ reason: toErrorMessage(error),
744
+ };
745
+ }
746
+ }
676
747
  emit(
677
748
  context.stdout,
678
- result.data,
749
+ output,
679
750
  context.json,
680
- `${result.data.task.id}: ${result.data.task.state}${result.data.task.publicIp ? ` (${result.data.task.publicIp})` : ""}`,
751
+ `${result.data.task.id}: ${result.data.task.state}${result.data.task.publicIp ? ` (${result.data.task.publicIp})` : ""}${notificationMessage}`,
681
752
  );
682
753
  return result.data.task.state === "manual_review" ? 6 : 0;
683
754
  }
@@ -771,8 +842,12 @@ async function handleCheckoutSubmit(client, store, options, context) {
771
842
  token,
772
843
  paymentSignature,
773
844
  });
845
+ const lifecycle = paymentLifecycle(response);
774
846
  if (response.data?.paymentId) {
775
847
  await store.saveOrderPaymentId(taskId, response.data.paymentId);
848
+ if (lifecycle.confirmed !== undefined) {
849
+ await store.saveOrderPaymentOutcome(taskId, response.data.paymentId, lifecycle);
850
+ }
776
851
  }
777
852
  const safe = await attachPaymentWorkflow(
778
853
  client,
@@ -791,9 +866,7 @@ async function handleCheckoutSubmit(client, store, options, context) {
791
866
  replacementAllowed: safe.replacementAllowed,
792
867
  });
793
868
  }
794
- const retryable =
795
- response.status === 202 &&
796
- ["payment_pending", "payment_finalizing"].includes(response.data?.status);
869
+ const retryable = paymentSubmissionPending(response, lifecycle);
797
870
  if (wait && retryable) {
798
871
  ensureBeforeDeadline(deadline, `Checkout ${taskId}`);
799
872
  await delay(suggestedDelay(response));
@@ -804,6 +877,8 @@ async function handleCheckoutSubmit(client, store, options, context) {
804
877
  ...safe,
805
878
  task: response.data?.task,
806
879
  paymentId: response.data?.paymentId,
880
+ confirmed: lifecycle.confirmed,
881
+ finalized: lifecycle.finalized,
807
882
  message: response.data?.message,
808
883
  ...(walletPayment
809
884
  ? {
@@ -853,8 +928,9 @@ function activeNotificationSubscription(notifications) {
853
928
  const subscription = notifications?.subscription;
854
929
  return Boolean(
855
930
  notifications?.configured &&
856
- subscription?.verified &&
857
- !subscription?.disabled,
931
+ !subscription?.disabled &&
932
+ Array.isArray(subscription?.recipients) &&
933
+ subscription.recipients.length > 0,
858
934
  );
859
935
  }
860
936
 
@@ -863,14 +939,14 @@ function refillNotificationState(notifications, policy) {
863
939
  if (!notifications?.configured || !subscription || subscription.disabled) {
864
940
  return {
865
941
  available: false,
866
- reason: "verified_email_required",
942
+ reason: "active_notification_recipient_required",
867
943
  subscription: subscription || null,
868
944
  };
869
945
  }
870
- if (!subscription.verified) {
946
+ if (!Array.isArray(subscription.recipients) || subscription.recipients.length === 0) {
871
947
  return {
872
948
  available: false,
873
- reason: "email_verification_required",
949
+ reason: "active_notification_recipient_required",
874
950
  subscription,
875
951
  };
876
952
  }
@@ -949,20 +1025,10 @@ async function handleRenewalConfigure(client, store, options, context) {
949
1025
  !withoutEmailNotifications &&
950
1026
  !activeNotificationSubscription(notifications)
951
1027
  ) {
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
1028
  const output = {
961
- action,
1029
+ action: "email_required",
962
1030
  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.",
1031
+ reason: "A human email enables renewal and wallet-refill notifications.",
966
1032
  notifications,
967
1033
  next: {
968
1034
  configureEmail: `warpmetal renewal configure ... --email <address>`,
@@ -974,27 +1040,27 @@ async function handleRenewalConfigure(client, store, options, context) {
974
1040
  context.stdout,
975
1041
  output,
976
1042
  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.`,
1043
+ `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
1044
  );
981
1045
  return 6;
982
1046
  }
983
1047
  if (email) {
984
1048
  notifications = (
985
- await client.putNotifications(serverId, { email }, token)
1049
+ await client.addNotificationRecipient(
1050
+ serverId,
1051
+ { email },
1052
+ token,
1053
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-add"),
1054
+ )
1055
+ ).data;
1056
+ } else if (withoutEmailNotifications) {
1057
+ notifications = (
1058
+ await client.deleteNotifications(
1059
+ serverId,
1060
+ token,
1061
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-disable"),
1062
+ )
986
1063
  ).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
1064
  }
999
1065
  const result = await client.putRenewalPolicy(serverId, body, token);
1000
1066
  await store.saveRenewalPolicy(
@@ -1009,8 +1075,8 @@ async function handleRenewalConfigure(client, store, options, context) {
1009
1075
  status: withoutEmailNotifications
1010
1076
  ? "opted_out"
1011
1077
  : activeNotificationSubscription(notifications)
1012
- ? "verified"
1013
- : "verification_required",
1078
+ ? "active"
1079
+ : "recipient_required",
1014
1080
  };
1015
1081
  const output = {
1016
1082
  ...result.data,
@@ -1023,7 +1089,7 @@ async function handleRenewalConfigure(client, store, options, context) {
1023
1089
  context.stdout,
1024
1090
  output,
1025
1091
  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." : ""}`,
1092
+ `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
1093
  );
1028
1094
  return 0;
1029
1095
  }
@@ -1257,9 +1323,9 @@ async function handleRenewalPrepare(client, store, options, context) {
1257
1323
  ? `\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
1324
  : "";
1259
1325
  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)}`
1326
+ ? `\nFor human refill notification, set ${Object.entries(safe.refillWorkflow.environment).map(([key, value]) => `${key}=${value}`).join(" ")} and run: ${shellCommand(safe.refillWorkflow.argv)}`
1261
1327
  : safe.paymentWorkflow
1262
- ? `\nVerified email refill is unavailable (${safe.refillNotification.reason}). Configure and verify notifications, or fund the displayed payer address directly.`
1328
+ ? `\nEmail refill is unavailable (${safe.refillNotification.reason}). Add a notification recipient, or fund the displayed payer address directly.`
1263
1329
  : "";
1264
1330
  const instructions = safe.paymentWorkflow
1265
1331
  ? `\nAuthorize: ${shellCommand(safe.paymentWorkflow.authorize.argv)}${fundingInstructions}${refillInstructions}\nSubmit: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
@@ -1378,8 +1444,16 @@ async function handleRenewalSubmit(client, store, options, context) {
1378
1444
  paymentSignature: walletPayment.paymentSignature,
1379
1445
  },
1380
1446
  );
1447
+ const lifecycle = paymentLifecycle(response);
1381
1448
  if (response.data?.paymentId) {
1382
1449
  await store.saveRenewalPaymentId(serverId, response.data.paymentId);
1450
+ if (lifecycle.confirmed !== undefined) {
1451
+ await store.saveRenewalPaymentOutcome(
1452
+ serverId,
1453
+ response.data.paymentId,
1454
+ lifecycle,
1455
+ );
1456
+ }
1383
1457
  }
1384
1458
  if (response.status === 402 && response.data?.status === "payment_rejected") {
1385
1459
  await store.saveRenewalPaymentRejection(serverId, {
@@ -1391,9 +1465,7 @@ async function handleRenewalSubmit(client, store, options, context) {
1391
1465
  replacementAllowed: response.data?.replacementAllowed,
1392
1466
  });
1393
1467
  }
1394
- const retryable =
1395
- response.status === 202 &&
1396
- ["payment_pending", "payment_finalizing"].includes(response.data?.status);
1468
+ const retryable = paymentSubmissionPending(response, lifecycle);
1397
1469
  if (booleanOption(options, "wait") && retryable) {
1398
1470
  ensureBeforeDeadline(deadline, `Renewal ${serverId}`);
1399
1471
  await delay(suggestedDelay(response));
@@ -1404,6 +1476,8 @@ async function handleRenewalSubmit(client, store, options, context) {
1404
1476
  serverId,
1405
1477
  task: response.data?.task,
1406
1478
  paymentId: response.data?.paymentId,
1479
+ confirmed: lifecycle.confirmed,
1480
+ finalized: lifecycle.finalized,
1407
1481
  paymentAttemptId:
1408
1482
  response.data?.paymentAttemptId ||
1409
1483
  response.headers["x-warpmetal-payment-attempt"],
@@ -1464,38 +1538,113 @@ async function handleRenewalDue(client, store, options, context) {
1464
1538
  return due.length ? 8 : 0;
1465
1539
  }
1466
1540
 
1467
- async function handleNotificationsConfigure(client, store, options, context) {
1541
+ function notificationEvents(options, { required = false } = {}) {
1542
+ const eventValue = stringOption(options, "events", { required });
1543
+ if (!eventValue) return undefined;
1544
+ const events = eventValue.split(",").map((value) => value.trim());
1545
+ if (events.some((value) => !value)) {
1546
+ throw new CliError("--events must contain comma-separated event names.", {
1547
+ exitCode: 2,
1548
+ });
1549
+ }
1550
+ return events;
1551
+ }
1552
+
1553
+ async function handleNotificationsAdd(client, store, options, context) {
1468
1554
  const serverId = stringOption(options, "server", { required: true });
1469
1555
  const email = stringOption(options, "email", { required: true });
1470
- const eventValue = stringOption(options, "events");
1471
1556
  const body = { email };
1472
- if (eventValue) body.events = eventValue.split(",").map((value) => value.trim());
1557
+ const events = notificationEvents(options);
1558
+ if (events) body.events = events;
1473
1559
  const token = await requireServerToken(store, serverId, options, context.env);
1474
- const result = await client.putNotifications(serverId, body, token);
1560
+ const result = await client.addNotificationRecipient(
1561
+ serverId,
1562
+ body,
1563
+ token,
1564
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-add"),
1565
+ );
1475
1566
  emit(
1476
1567
  context.stdout,
1477
1568
  result.data,
1478
1569
  context.json,
1479
- `Verification email queued for ${result.data.subscription.email}.`,
1570
+ result.data.created
1571
+ ? `${result.data.recipient.email} is active for ${serverId}; WarpMetal queued a security advisory to that address.`
1572
+ : `${result.data.recipient.email} was already active for ${serverId}.`,
1480
1573
  );
1481
1574
  return 0;
1482
1575
  }
1483
1576
 
1484
- async function handleNotificationsStatus(client, store, options, context) {
1577
+ async function handleNotificationsList(client, store, options, context) {
1485
1578
  const serverId = stringOption(options, "server", { required: true });
1486
1579
  const token = await requireServerToken(store, serverId, options, context.env);
1487
1580
  const result = await client.getNotifications(serverId, token);
1581
+ const count = result.data.subscription?.recipients?.length || 0;
1488
1582
  emit(
1489
1583
  context.stdout,
1490
1584
  result.data,
1491
1585
  context.json,
1492
1586
  result.data.configured
1493
- ? `${serverId}: notifications ${result.data.subscription.verified ? "verified" : "awaiting verification"}`
1587
+ ? `${serverId}: ${count} active notification recipient${count === 1 ? "" : "s"}`
1494
1588
  : `${serverId}: notifications are not configured`,
1495
1589
  );
1496
1590
  return 0;
1497
1591
  }
1498
1592
 
1593
+ async function handleNotificationsRemove(client, store, options, context) {
1594
+ const serverId = stringOption(options, "server", { required: true });
1595
+ const recipientId = stringOption(options, "recipient", { required: true });
1596
+ const token = await requireServerToken(store, serverId, options, context.env);
1597
+ const result = await client.deleteNotificationRecipient(
1598
+ serverId,
1599
+ recipientId,
1600
+ token,
1601
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-remove"),
1602
+ );
1603
+ emit(
1604
+ context.stdout,
1605
+ result.data,
1606
+ context.json,
1607
+ `Removed ${recipientId} from ${serverId} notifications.`,
1608
+ );
1609
+ return 0;
1610
+ }
1611
+
1612
+ async function handleNotificationsEvents(client, store, options, context) {
1613
+ const serverId = stringOption(options, "server", { required: true });
1614
+ const events = notificationEvents(options, { required: true });
1615
+ const token = await requireServerToken(store, serverId, options, context.env);
1616
+ const result = await client.patchNotifications(
1617
+ serverId,
1618
+ { events },
1619
+ token,
1620
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-events"),
1621
+ );
1622
+ emit(
1623
+ context.stdout,
1624
+ result.data,
1625
+ context.json,
1626
+ `Updated notification events for ${serverId}.`,
1627
+ );
1628
+ return 0;
1629
+ }
1630
+
1631
+ async function handleNotificationsDisable(client, store, options, context) {
1632
+ const serverId = stringOption(options, "server", { required: true });
1633
+ const token = await requireServerToken(store, serverId, options, context.env);
1634
+ const result = await client.deleteNotifications(
1635
+ serverId,
1636
+ token,
1637
+ stringOption(options, "idempotency-key") || idempotencyKey("notification-disable"),
1638
+ );
1639
+ emit(
1640
+ context.stdout,
1641
+ result.data,
1642
+ context.json,
1643
+ `Disabled lifecycle notifications for ${serverId}.`,
1644
+ );
1645
+ return 0;
1646
+ }
1647
+
1499
1648
  async function loginServer(client, store, serverId, identity) {
1500
1649
  const challenge = (await client.issueSshChallenge(serverId)).data;
1501
1650
  const signature = await signSshChallenge(challenge.payload, identity);
@@ -2553,6 +2702,7 @@ async function dispatch(positionals, options, passthrough, context) {
2553
2702
  "email",
2554
2703
  "without-email-notifications",
2555
2704
  "disabled",
2705
+ "idempotency-key",
2556
2706
  ]);
2557
2707
  return handleRenewalConfigure(client, store, options, context);
2558
2708
  case "renewal status":
@@ -2597,6 +2747,7 @@ async function dispatch(positionals, options, passthrough, context) {
2597
2747
  "request-envelope-out",
2598
2748
  ]);
2599
2749
  return handleRenewalRun(client, store, options, context);
2750
+ case "notifications add":
2600
2751
  case "notifications configure":
2601
2752
  rejectUnknownOptions(options, [
2602
2753
  ...COMMON_OPTIONS,
@@ -2604,15 +2755,43 @@ async function dispatch(positionals, options, passthrough, context) {
2604
2755
  "token-file",
2605
2756
  "email",
2606
2757
  "events",
2758
+ "idempotency-key",
2607
2759
  ]);
2608
- return handleNotificationsConfigure(client, store, options, context);
2760
+ return handleNotificationsAdd(client, store, options, context);
2761
+ case "notifications list":
2609
2762
  case "notifications status":
2610
2763
  rejectUnknownOptions(options, [
2611
2764
  ...COMMON_OPTIONS,
2612
2765
  "server",
2613
2766
  "token-file",
2614
2767
  ]);
2615
- return handleNotificationsStatus(client, store, options, context);
2768
+ return handleNotificationsList(client, store, options, context);
2769
+ case "notifications remove":
2770
+ rejectUnknownOptions(options, [
2771
+ ...COMMON_OPTIONS,
2772
+ "server",
2773
+ "token-file",
2774
+ "recipient",
2775
+ "idempotency-key",
2776
+ ]);
2777
+ return handleNotificationsRemove(client, store, options, context);
2778
+ case "notifications events":
2779
+ rejectUnknownOptions(options, [
2780
+ ...COMMON_OPTIONS,
2781
+ "server",
2782
+ "token-file",
2783
+ "events",
2784
+ "idempotency-key",
2785
+ ]);
2786
+ return handleNotificationsEvents(client, store, options, context);
2787
+ case "notifications disable":
2788
+ rejectUnknownOptions(options, [
2789
+ ...COMMON_OPTIONS,
2790
+ "server",
2791
+ "token-file",
2792
+ "idempotency-key",
2793
+ ]);
2794
+ return handleNotificationsDisable(client, store, options, context);
2616
2795
  case "server login":
2617
2796
  rejectUnknownOptions(options, [...COMMON_OPTIONS, "server", "identity"]);
2618
2797
  return handleServerLogin(client, store, options, context);
package/src/payment.js CHANGED
@@ -15,7 +15,7 @@ const MAX_ARTIFACT_BYTES = 1024 * 1024;
15
15
  const MAX_SIGNATURE_BYTES = 512 * 1024;
16
16
 
17
17
  export const AGENT_WALLET_PACKAGE = "@x402api/agent-wallet-cli";
18
- export const AGENT_WALLET_VERSION = "0.2.8";
18
+ export const AGENT_WALLET_VERSION = "0.2.9";
19
19
  const AGENT_WALLET_SPEC = `${AGENT_WALLET_PACKAGE}@${AGENT_WALLET_VERSION}`;
20
20
  const GAS_SPONSORSHIP_EXTENSION = "com.x402api.gas-sponsorship";
21
21
  const BASE_NETWORK = "eip155:8453";
@@ -842,6 +842,9 @@ export function paymentWorkflow({
842
842
  signerContract: {
843
843
  version: 1,
844
844
  probe: { argv: ["x402api", "help", "--json"] },
845
+ requiredCommands: [
846
+ "payment authorize --wallet <name> --request-envelope <file> --artifact-out <file>",
847
+ ],
845
848
  },
846
849
  signerReleaseStatus:
847
850
  "published launch payer; sponsored Base USDC and Solana USDC/USDT only",
package/src/state.js CHANGED
@@ -14,6 +14,8 @@ const WALLET_PAYMENT_STATE_FIELDS = [
14
14
  "paymentArtifactExpiresAt",
15
15
  "paymentArtifactSavedAt",
16
16
  "gatewayPaymentId",
17
+ "gatewayPaymentConfirmed",
18
+ "gatewayPaymentFinalized",
17
19
  "rejectedPaymentAttemptId",
18
20
  "paymentRejectionErrorCode",
19
21
  "paymentRejectionRequestId",
@@ -437,6 +439,43 @@ export class StateStore {
437
439
  });
438
440
  }
439
441
 
442
+ async saveOrderPaymentOutcome(taskId, paymentId, { confirmed, finalized }) {
443
+ if (
444
+ !X402API_PAYMENT_ID.test(paymentId || "") ||
445
+ typeof confirmed !== "boolean" ||
446
+ typeof finalized !== "boolean" ||
447
+ (finalized && !confirmed)
448
+ ) {
449
+ throw new CliError("WarpMetal returned invalid payment lifecycle evidence.", {
450
+ exitCode: 3,
451
+ });
452
+ }
453
+ await this.update((state) => {
454
+ const order = state.orders[taskId];
455
+ if (!order) {
456
+ throw new CliError(`No local order state exists for ${taskId}.`, {
457
+ exitCode: 2,
458
+ });
459
+ }
460
+ if (order.gatewayPaymentId && order.gatewayPaymentId !== paymentId) {
461
+ throw new CliError("WarpMetal changed the x402api payment ID for this attempt.", {
462
+ exitCode: 3,
463
+ });
464
+ }
465
+ if (
466
+ (order.gatewayPaymentConfirmed === true && !confirmed) ||
467
+ (order.gatewayPaymentFinalized === true && !finalized)
468
+ ) {
469
+ throw new CliError("WarpMetal payment lifecycle evidence moved backward.", {
470
+ exitCode: 3,
471
+ });
472
+ }
473
+ order.gatewayPaymentId = paymentId;
474
+ order.gatewayPaymentConfirmed = confirmed;
475
+ order.gatewayPaymentFinalized = finalized;
476
+ });
477
+ }
478
+
440
479
  async saveOrderPaymentRejection(taskId, rejection) {
441
480
  const safe = validatedPaymentRejection(rejection);
442
481
  await this.update((state) => {
@@ -481,6 +520,43 @@ export class StateStore {
481
520
  });
482
521
  }
483
522
 
523
+ async saveRenewalPaymentOutcome(serverId, paymentId, { confirmed, finalized }) {
524
+ if (
525
+ !X402API_PAYMENT_ID.test(paymentId || "") ||
526
+ typeof confirmed !== "boolean" ||
527
+ typeof finalized !== "boolean" ||
528
+ (finalized && !confirmed)
529
+ ) {
530
+ throw new CliError("WarpMetal returned invalid payment lifecycle evidence.", {
531
+ exitCode: 3,
532
+ });
533
+ }
534
+ await this.update((state) => {
535
+ const renewal = state.renewals[serverId];
536
+ if (!renewal) {
537
+ throw new CliError(`No local renewal state exists for ${serverId}.`, {
538
+ exitCode: 2,
539
+ });
540
+ }
541
+ if (renewal.gatewayPaymentId && renewal.gatewayPaymentId !== paymentId) {
542
+ throw new CliError("WarpMetal changed the x402api payment ID for this renewal.", {
543
+ exitCode: 3,
544
+ });
545
+ }
546
+ if (
547
+ (renewal.gatewayPaymentConfirmed === true && !confirmed) ||
548
+ (renewal.gatewayPaymentFinalized === true && !finalized)
549
+ ) {
550
+ throw new CliError("WarpMetal renewal payment lifecycle evidence moved backward.", {
551
+ exitCode: 3,
552
+ });
553
+ }
554
+ renewal.gatewayPaymentId = paymentId;
555
+ renewal.gatewayPaymentConfirmed = confirmed;
556
+ renewal.gatewayPaymentFinalized = finalized;
557
+ });
558
+ }
559
+
484
560
  async saveRenewalPaymentRejection(serverId, rejection) {
485
561
  const safe = validatedPaymentRejection(rejection);
486
562
  await this.update((state) => {
@@ -631,6 +707,8 @@ export class StateStore {
631
707
  planId: order.planId,
632
708
  paymentAttemptId: order.paymentAttemptId,
633
709
  paymentId: order.gatewayPaymentId,
710
+ paymentConfirmed: order.gatewayPaymentConfirmed,
711
+ paymentFinalized: order.gatewayPaymentFinalized,
634
712
  challengeHandle: order.challengeHandle,
635
713
  walletPaymentAttemptId: order.walletPaymentAttemptId,
636
714
  walletName: order.walletName,
@@ -671,6 +749,8 @@ export class StateStore {
671
749
  policy: renewal.policy,
672
750
  paymentAttemptId: renewal.paymentAttemptId,
673
751
  paymentId: renewal.gatewayPaymentId,
752
+ paymentConfirmed: renewal.gatewayPaymentConfirmed,
753
+ paymentFinalized: renewal.gatewayPaymentFinalized,
674
754
  challengeHandle: renewal.challengeHandle,
675
755
  paymentArtifactExpiresAt: renewal.paymentArtifactExpiresAt,
676
756
  rejectedPaymentAttemptId: renewal.rejectedPaymentAttemptId,