viveworker 0.7.0-beta.2 → 0.7.0-beta.3
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 +10 -0
- package/package.json +2 -1
- package/scripts/share-cli.mjs +553 -3
- package/scripts/viveworker-bridge.mjs +791 -44
- package/viveworker.env.example +4 -0
- package/web/app.css +29 -0
- package/web/app.js +127 -11
- package/web/i18n.js +22 -0
- package/web/sw.js +1 -1
package/README.md
CHANGED
|
@@ -187,6 +187,15 @@ Typical commands:
|
|
|
187
187
|
- `npx viveworker share update <slug> --password "hunter2"`
|
|
188
188
|
- `npx viveworker share update <slug> --expires-days 7`
|
|
189
189
|
- `npx viveworker share link <slug>`
|
|
190
|
+
- `VIVEWORKER_BUYER_PRIVATE_KEY=0x... npx viveworker share pay https://share.viveworker.com/v/<slug> --output ./deliverable.pdf`
|
|
191
|
+
- `npx viveworker share pay https://share.viveworker.com/v/<slug> --wallet hazbase --output ./deliverable.pdf`
|
|
192
|
+
|
|
193
|
+
`share pay` is human-in-the-loop by default. EOA mode reads the x402 payment
|
|
194
|
+
requirements and asks the paired device to approve before signing. `--wallet
|
|
195
|
+
hazbase` instead sends the payment request to the paired device, asks for
|
|
196
|
+
Passkey reauth, and pays from the configured hazBase Smart Wallet. Use
|
|
197
|
+
`--dry-run` to inspect without signing, or `--no-approval` / `--yes` only for
|
|
198
|
+
trusted EOA smoke tests and CI.
|
|
190
199
|
|
|
191
200
|
The current public File Share surface is focused on private static artefact delivery from your Mac and your agents.
|
|
192
201
|
|
|
@@ -206,6 +215,7 @@ When the seller wants payouts to go to a hazbase-managed wallet instead of a raw
|
|
|
206
215
|
- the human completes OTP / passkey / wallet issuance in `Settings -> Integrations -> Wallet`
|
|
207
216
|
- the agent resolves the local payout address from `/api/hazbase/payout-address`
|
|
208
217
|
- the agent passes that resolved address to `share upload` / `share update --pay-to`
|
|
218
|
+
- the buyer agent can inspect with `share pay <url> --dry-run`, then request phone approval and unlock with `VIVEWORKER_BUYER_PRIVATE_KEY` / `BUYER_PK`
|
|
209
219
|
|
|
210
220
|
This is not meant as a generic "payments feature."
|
|
211
221
|
The interesting part is the agent workflow: request, delivery, handoff, and unlock stay cleanly separated.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
3
|
+
"version": "0.7.0-beta.3",
|
|
4
4
|
"description": "Open mobile control surface for Codex, Claude, Thread Sharing, File Share, Moltbook, and A2A tasks on your trusted LAN.",
|
|
5
5
|
"author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
],
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@hazbase/auth": "^0.5.0",
|
|
65
|
+
"ethers": "^6.16.0",
|
|
65
66
|
"qrcode-terminal": "^0.12.0",
|
|
66
67
|
"web-push": "^3.6.7"
|
|
67
68
|
},
|
package/scripts/share-cli.mjs
CHANGED
|
@@ -11,26 +11,50 @@
|
|
|
11
11
|
* viveworker share list [--json]
|
|
12
12
|
* viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]
|
|
13
13
|
* viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]
|
|
14
|
+
* viveworker share pay <url> [--output <file>] [--dry-run] [--wallet eoa|hazbase] [--no-approval] [--json]
|
|
14
15
|
* viveworker share delete <slug>
|
|
15
16
|
*
|
|
16
17
|
* Paid shares: `--price 0.10 --pay-to 0x…` attaches an x402 payment gate.
|
|
17
18
|
* Buyers hit HTTP 402 on first view, pay USDC on Base (testnet or mainnet
|
|
18
19
|
* depending on worker config), and the worker serves the content. Any
|
|
19
|
-
* x402-compatible
|
|
20
|
-
*
|
|
20
|
+
* x402-compatible clients can pay. This CLI ships a minimal buyer flow for
|
|
21
|
+
* Base/Base Sepolia using VIVEWORKER_BUYER_PRIVATE_KEY or BUYER_PK. Before
|
|
22
|
+
* signing, non-dry-run EOA payments are sent to the paired viveworker device for
|
|
23
|
+
* human approval; --wallet hazbase asks the paired device to reauth with passkey
|
|
24
|
+
* and pay from the configured hazBase Smart Wallet.
|
|
21
25
|
*
|
|
22
26
|
* Environment overrides:
|
|
23
27
|
* VIVEWORKER_SHARE_URL — share worker base URL (default: https://share.viveworker.com)
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import { promises as fs } from "node:fs";
|
|
31
|
+
import crypto from "node:crypto";
|
|
32
|
+
import http from "node:http";
|
|
33
|
+
import https from "node:https";
|
|
27
34
|
import os from "node:os";
|
|
28
35
|
import path from "node:path";
|
|
29
36
|
import { Blob, File } from "node:buffer";
|
|
30
37
|
|
|
31
38
|
const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
|
|
39
|
+
const CONFIG_ENV_FILE = path.join(os.homedir(), ".viveworker", "config.env");
|
|
32
40
|
const DEFAULT_SHARE_URL = "https://share.viveworker.com";
|
|
33
41
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // mirror worker
|
|
42
|
+
const X402_VERSION = 1;
|
|
43
|
+
const PAYMENT_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
44
|
+
const SUPPORTED_BUYER_NETWORKS = {
|
|
45
|
+
base: {
|
|
46
|
+
chainId: 8453,
|
|
47
|
+
label: "Base",
|
|
48
|
+
usdcName: "USD Coin",
|
|
49
|
+
usdcVersion: "2",
|
|
50
|
+
},
|
|
51
|
+
"base-sepolia": {
|
|
52
|
+
chainId: 84532,
|
|
53
|
+
label: "Base Sepolia",
|
|
54
|
+
usdcName: "USDC",
|
|
55
|
+
usdcVersion: "2",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
34
58
|
|
|
35
59
|
// Mirror share-worker/worker.js SHARE_TYPES. Keep in sync by inspection —
|
|
36
60
|
// scripts/ and share-worker/ don't share a module. Adding a new type here
|
|
@@ -64,6 +88,8 @@ export async function runShareCli(args) {
|
|
|
64
88
|
return handleUpdate(args.slice(1));
|
|
65
89
|
case "link":
|
|
66
90
|
return handleLink(args.slice(1));
|
|
91
|
+
case "pay":
|
|
92
|
+
return handlePay(args.slice(1));
|
|
67
93
|
case "delete":
|
|
68
94
|
case "rm":
|
|
69
95
|
return handleDelete(args.slice(1));
|
|
@@ -81,6 +107,7 @@ function printHelp() {
|
|
|
81
107
|
console.log(" viveworker share list [--metrics] [--json]");
|
|
82
108
|
console.log(" viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]");
|
|
83
109
|
console.log(" viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
|
|
110
|
+
console.log(" viveworker share pay <url> [--output <file>] [--dry-run] [--no-approval] [--json]");
|
|
84
111
|
console.log(" viveworker share delete <slug>");
|
|
85
112
|
console.log("");
|
|
86
113
|
console.log(`Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}`);
|
|
@@ -88,7 +115,9 @@ function printHelp() {
|
|
|
88
115
|
console.log("HTML uploads are optimized by default when possible (use --no-optimize to disable).");
|
|
89
116
|
console.log("");
|
|
90
117
|
console.log("Paid shares (x402 / USDC on Base — CLOSED BETA, testnet only): --price 0.10 --pay-to 0x…");
|
|
91
|
-
console.log(" Buyers use
|
|
118
|
+
console.log(" Buyers can use: VIVEWORKER_BUYER_PRIVATE_KEY=0x… viveworker share pay <url>");
|
|
119
|
+
console.log(" Non-dry-run payments require paired-device approval before signing.");
|
|
120
|
+
console.log(" Use --no-approval / --yes only for trusted test automation.");
|
|
92
121
|
console.log(" To use a hazbase wallet as payTo, resolve it first via the local /api/hazbase/payout-address endpoint.");
|
|
93
122
|
console.log(" --price and --password are mutually exclusive on a single share.");
|
|
94
123
|
console.log(" `share list --metrics` prints 24h / 7d payment-flow stats for your shares.");
|
|
@@ -699,6 +728,126 @@ async function handleLink(args) {
|
|
|
699
728
|
console.log("");
|
|
700
729
|
}
|
|
701
730
|
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// pay
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
async function handlePay(args) {
|
|
736
|
+
const flags = parseFlags(args);
|
|
737
|
+
const targetUrl = flags._[0];
|
|
738
|
+
if (!targetUrl) {
|
|
739
|
+
throw new Error("Usage: viveworker share pay <url> [--output <file>] [--dry-run] [--json]");
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let url;
|
|
743
|
+
try {
|
|
744
|
+
url = new URL(targetUrl).toString();
|
|
745
|
+
} catch {
|
|
746
|
+
throw new Error("share pay requires an absolute http(s) URL");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const initial = await fetchWithTimeout(url, {
|
|
750
|
+
headers: {
|
|
751
|
+
accept: "application/json, application/x-x402+json;q=0.9, */*;q=0.1",
|
|
752
|
+
},
|
|
753
|
+
}, 30_000);
|
|
754
|
+
const initialBytes = Buffer.from(await initial.arrayBuffer());
|
|
755
|
+
const initialText = initialBytes.toString("utf8");
|
|
756
|
+
|
|
757
|
+
if (initial.status !== 402) {
|
|
758
|
+
if (flags.json) {
|
|
759
|
+
console.log(JSON.stringify({
|
|
760
|
+
paid: false,
|
|
761
|
+
status: initial.status,
|
|
762
|
+
contentType: initial.headers.get("content-type") || "",
|
|
763
|
+
note: initial.ok ? "resource did not require payment" : "resource did not return x402 payment requirements",
|
|
764
|
+
}, null, 2));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (initial.ok) {
|
|
768
|
+
console.log(`No payment required (${initial.status}).`);
|
|
769
|
+
await writeOrPreviewPaidBody(flags, initial, initialBytes);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
throw new Error(`Expected HTTP 402 payment requirements, got ${initial.status}`);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const x402 = parseX402Body(initialText);
|
|
776
|
+
const requirement = selectPaymentRequirement(x402);
|
|
777
|
+
const paymentSummary = summarizeRequirement(requirement);
|
|
778
|
+
if (flags["dry-run"] || flags.dryRun) {
|
|
779
|
+
const dryRun = {
|
|
780
|
+
paid: false,
|
|
781
|
+
dryRun: true,
|
|
782
|
+
url,
|
|
783
|
+
...paymentSummary,
|
|
784
|
+
};
|
|
785
|
+
if (flags.json) {
|
|
786
|
+
console.log(JSON.stringify(dryRun, null, 2));
|
|
787
|
+
} else {
|
|
788
|
+
printPaymentSummary(dryRun);
|
|
789
|
+
console.log("Dry run only; no payment was signed.");
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const walletMode = resolveBuyerWalletMode(flags);
|
|
795
|
+
const payment = walletMode === "hazbase"
|
|
796
|
+
? await requestHazbaseWalletPayment({ url, x402, requirement, paymentSummary, flags })
|
|
797
|
+
: await requestEoaPayment({ url, requirement, paymentSummary, flags });
|
|
798
|
+
const paid = await fetchWithTimeout(url, {
|
|
799
|
+
headers: {
|
|
800
|
+
accept: "*/*",
|
|
801
|
+
"x-payment": payment.header,
|
|
802
|
+
},
|
|
803
|
+
}, 60_000);
|
|
804
|
+
const paidBytes = Buffer.from(await paid.arrayBuffer());
|
|
805
|
+
const responsePreview = decodePaymentResponseHeader(paid.headers.get("x-payment-response"));
|
|
806
|
+
|
|
807
|
+
if (!paid.ok) {
|
|
808
|
+
let body = {};
|
|
809
|
+
try {
|
|
810
|
+
body = JSON.parse(paidBytes.toString("utf8"));
|
|
811
|
+
} catch {
|
|
812
|
+
body = { error: paidBytes.toString("utf8").slice(0, 200) };
|
|
813
|
+
}
|
|
814
|
+
throw new Error(formatApiError("Payment", paid.status, body));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const result = {
|
|
818
|
+
paid: true,
|
|
819
|
+
status: paid.status,
|
|
820
|
+
url,
|
|
821
|
+
payer: payment.payer,
|
|
822
|
+
...paymentSummary,
|
|
823
|
+
xPaymentResponse: responsePreview,
|
|
824
|
+
contentType: paid.headers.get("content-type") || "",
|
|
825
|
+
bytes: paidBytes.length,
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
if (flags.output) {
|
|
829
|
+
const outputPath = path.resolve(String(flags.output));
|
|
830
|
+
await fs.writeFile(outputPath, paidBytes);
|
|
831
|
+
result.output = outputPath;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (flags.json) {
|
|
835
|
+
console.log(JSON.stringify(result, null, 2));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
printPaymentSummary(result);
|
|
840
|
+
if (result.xPaymentResponse) {
|
|
841
|
+
const tx = result.xPaymentResponse.transactionHash || result.xPaymentResponse.txHash || "";
|
|
842
|
+
if (tx) console.log(` tx: ${tx}`);
|
|
843
|
+
}
|
|
844
|
+
if (result.output) {
|
|
845
|
+
console.log(` saved: ${result.output}`);
|
|
846
|
+
} else {
|
|
847
|
+
previewPaidBody(result.contentType, paidBytes);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
702
851
|
// ---------------------------------------------------------------------------
|
|
703
852
|
// delete
|
|
704
853
|
// ---------------------------------------------------------------------------
|
|
@@ -736,6 +885,407 @@ async function handleDelete(args) {
|
|
|
736
885
|
console.log(`✅ Deleted ${slug}`);
|
|
737
886
|
}
|
|
738
887
|
|
|
888
|
+
function parseX402Body(text) {
|
|
889
|
+
try {
|
|
890
|
+
const parsed = JSON.parse(text);
|
|
891
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
892
|
+
} catch {}
|
|
893
|
+
|
|
894
|
+
const match = String(text || "").match(
|
|
895
|
+
/<script[^>]+type=["']application\/x-x402\+json["'][^>]*>([\s\S]*?)<\/script>/iu
|
|
896
|
+
);
|
|
897
|
+
if (match) {
|
|
898
|
+
try {
|
|
899
|
+
return JSON.parse(match[1]);
|
|
900
|
+
} catch {}
|
|
901
|
+
}
|
|
902
|
+
throw new Error("HTTP 402 response did not contain a valid x402 JSON body");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function selectPaymentRequirement(x402) {
|
|
906
|
+
const accepts = Array.isArray(x402?.accepts) ? x402.accepts : [];
|
|
907
|
+
const requirement = accepts.find((item) =>
|
|
908
|
+
item &&
|
|
909
|
+
item.scheme === "exact" &&
|
|
910
|
+
Object.prototype.hasOwnProperty.call(SUPPORTED_BUYER_NETWORKS, String(item.network || ""))
|
|
911
|
+
);
|
|
912
|
+
if (!requirement) {
|
|
913
|
+
const networks = accepts.map((item) => item?.network).filter(Boolean).join(", ") || "none";
|
|
914
|
+
throw new Error(`No supported x402 exact payment option found (offered: ${networks})`);
|
|
915
|
+
}
|
|
916
|
+
if (!ETH_ADDR_REGEX.test(String(requirement.payTo || ""))) {
|
|
917
|
+
throw new Error("x402 payment requirement has an invalid payTo address");
|
|
918
|
+
}
|
|
919
|
+
if (!ETH_ADDR_REGEX.test(String(requirement.asset || ""))) {
|
|
920
|
+
throw new Error("x402 payment requirement has an invalid asset address");
|
|
921
|
+
}
|
|
922
|
+
if (!/^\d+$/u.test(String(requirement.maxAmountRequired || ""))) {
|
|
923
|
+
throw new Error("x402 payment requirement has an invalid amount");
|
|
924
|
+
}
|
|
925
|
+
return requirement;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function summarizeRequirement(requirement) {
|
|
929
|
+
const network = SUPPORTED_BUYER_NETWORKS[String(requirement.network)];
|
|
930
|
+
return {
|
|
931
|
+
network: String(requirement.network),
|
|
932
|
+
chainId: network.chainId,
|
|
933
|
+
amountAtomic: String(requirement.maxAmountRequired),
|
|
934
|
+
amountUsdc: formatUsdc(requirement.maxAmountRequired),
|
|
935
|
+
payTo: String(requirement.payTo),
|
|
936
|
+
asset: String(requirement.asset),
|
|
937
|
+
resource: String(requirement.resource || ""),
|
|
938
|
+
description: String(requirement.description || ""),
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function printPaymentSummary(summary) {
|
|
943
|
+
const network = SUPPORTED_BUYER_NETWORKS[summary.network];
|
|
944
|
+
console.log("");
|
|
945
|
+
console.log(`${summary.paid ? "Paid" : "Payment required"} — ${summary.amountUsdc} USDC on ${network?.label || summary.network}`);
|
|
946
|
+
console.log(` to: ${summary.payTo}`);
|
|
947
|
+
if (summary.payer) console.log(` from: ${summary.payer}`);
|
|
948
|
+
if (summary.resource) console.log(` resource: ${summary.resource}`);
|
|
949
|
+
console.log("");
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
function resolveBuyerWalletMode(flags) {
|
|
954
|
+
const raw = String(flags.wallet || flags["buyer-wallet"] || flags.buyerWallet || flags["payment-wallet"] || "eoa").trim().toLowerCase();
|
|
955
|
+
if (!raw || raw === "eoa" || raw === "private-key" || raw === "private_key") return "eoa";
|
|
956
|
+
if (raw === "hazbase" || raw === "hazbase-wallet" || raw === "smart-wallet" || raw === "smart_wallet") return "hazbase";
|
|
957
|
+
throw new Error("--wallet must be either eoa or hazbase");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function requestEoaPayment({ url, requirement, paymentSummary, flags }) {
|
|
961
|
+
await requirePaymentApproval({ url, paymentSummary, flags });
|
|
962
|
+
const privateKey = resolveBuyerPrivateKey();
|
|
963
|
+
const payment = await buildXPaymentHeader(requirement, privateKey);
|
|
964
|
+
return {
|
|
965
|
+
header: payment.header,
|
|
966
|
+
payer: payment.payer,
|
|
967
|
+
payload: payment.payload,
|
|
968
|
+
walletMode: "eoa",
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function requestHazbaseWalletPayment({ url, x402, requirement, paymentSummary, flags }) {
|
|
973
|
+
const paymentRequestId = cleanPaymentRequestId(
|
|
974
|
+
x402?.paymentRequestId || x402?.hazbase?.paymentRequestId || requirement?.extra?.paymentRequestId || ""
|
|
975
|
+
);
|
|
976
|
+
if (!paymentRequestId) {
|
|
977
|
+
throw new Error("This 402 response does not expose a hazBase paymentRequestId; --wallet hazbase requires a hazBase-backed share.");
|
|
978
|
+
}
|
|
979
|
+
const config = await resolveApprovalBridgeConfig();
|
|
980
|
+
if (!config.baseUrl || !config.sessionSecret) {
|
|
981
|
+
throw new Error(
|
|
982
|
+
"Hazbase Smart Wallet payment requires the local viveworker bridge. " +
|
|
983
|
+
`Start viveworker or check ${CONFIG_ENV_FILE}.`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
const timeoutMs = resolveApprovalTimeoutMs(flags, config.envText);
|
|
987
|
+
if (!flags.json) {
|
|
988
|
+
console.log(`Waiting for paired-device hazBase wallet payment (${Math.round(timeoutMs / 1000)}s timeout)...`);
|
|
989
|
+
}
|
|
990
|
+
const response = await postBridgeJson(config, "/api/payments/x402/hazbase-wallet", {
|
|
991
|
+
paymentRequestId,
|
|
992
|
+
url,
|
|
993
|
+
payment: paymentSummary,
|
|
994
|
+
createdAtMs: Date.now(),
|
|
995
|
+
timeoutMs,
|
|
996
|
+
}, timeoutMs + 5_000);
|
|
997
|
+
if (!response.ok || response.body?.paid !== true || !response.body?.xPayment) {
|
|
998
|
+
const code = response.body?.error || response.body?.decision || `http-${response.status}`;
|
|
999
|
+
throw new Error(`Hazbase Smart Wallet payment was not completed on the paired device (${code}).`);
|
|
1000
|
+
}
|
|
1001
|
+
const paid = response.body.payment || response.body;
|
|
1002
|
+
return {
|
|
1003
|
+
header: String(response.body.xPayment),
|
|
1004
|
+
payer: String(paid.payer || response.body.payer || ""),
|
|
1005
|
+
payload: response.body.paymentProof || null,
|
|
1006
|
+
walletMode: "hazbase",
|
|
1007
|
+
submittedUserOpHash: response.body.submittedUserOpHash || "",
|
|
1008
|
+
transactionHash: response.body.transactionHash || "",
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function cleanPaymentRequestId(value) {
|
|
1013
|
+
const text = String(value || "").trim();
|
|
1014
|
+
return /^[a-zA-Z0-9:_-]{8,160}$/u.test(text) ? text : "";
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function requirePaymentApproval({ url, paymentSummary, flags }) {
|
|
1018
|
+
const config = await resolveApprovalBridgeConfig();
|
|
1019
|
+
if (shouldSkipPaymentApproval(flags, config.envText)) {
|
|
1020
|
+
if (!flags.json) {
|
|
1021
|
+
console.warn("Skipping paired-device payment approval (--no-approval / --yes).");
|
|
1022
|
+
}
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (!config.baseUrl || !config.sessionSecret) {
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
"Payment approval is required before signing, but the local viveworker bridge is not configured. " +
|
|
1029
|
+
`Start viveworker or check ${CONFIG_ENV_FILE}. Use --no-approval only for trusted test automation.`
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const timeoutMs = resolveApprovalTimeoutMs(flags, config.envText);
|
|
1034
|
+
if (!flags.json) {
|
|
1035
|
+
console.log(`Waiting for paired-device approval (${Math.round(timeoutMs / 1000)}s timeout)...`);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const body = {
|
|
1039
|
+
paymentRequestId: `x402:${crypto.randomUUID()}`,
|
|
1040
|
+
url,
|
|
1041
|
+
payment: paymentSummary,
|
|
1042
|
+
createdAtMs: Date.now(),
|
|
1043
|
+
timeoutMs,
|
|
1044
|
+
};
|
|
1045
|
+
const response = await postApprovalRequest(config, body, timeoutMs + 5_000);
|
|
1046
|
+
if (!response.ok || response.body?.approved !== true) {
|
|
1047
|
+
const code = response.body?.error || response.body?.decision || `http-${response.status}`;
|
|
1048
|
+
throw new Error(`Payment was not approved on the paired device (${code}).`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const approved = response.body?.approvedPayment || {};
|
|
1052
|
+
assertApprovedPaymentMatches(paymentSummary, approved);
|
|
1053
|
+
if (!flags.json) {
|
|
1054
|
+
console.log("Payment approved on paired device.");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function shouldSkipPaymentApproval(flags, envText = "") {
|
|
1059
|
+
if (flags["no-approval"] || flags.noApproval || flags.yes || flags.y) {
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
const raw = String(process.env.VIVEWORKER_PAYMENT_APPROVALS || envValue(envText, "VIVEWORKER_PAYMENT_APPROVALS") || "").trim().toLowerCase();
|
|
1063
|
+
return raw === "0" || raw === "false" || raw === "off" || raw === "skip";
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function resolveApprovalTimeoutMs(flags, envText = "") {
|
|
1067
|
+
const raw =
|
|
1068
|
+
flags["approval-timeout"] ||
|
|
1069
|
+
flags.approvalTimeout ||
|
|
1070
|
+
process.env.VIVEWORKER_PAYMENT_APPROVAL_TIMEOUT_SEC ||
|
|
1071
|
+
envValue(envText, "VIVEWORKER_PAYMENT_APPROVAL_TIMEOUT_SEC") ||
|
|
1072
|
+
"";
|
|
1073
|
+
if (!raw) return PAYMENT_APPROVAL_TIMEOUT_MS;
|
|
1074
|
+
const seconds = Number(raw);
|
|
1075
|
+
if (!Number.isFinite(seconds) || seconds < 10 || seconds > 900) {
|
|
1076
|
+
throw new Error("--approval-timeout must be between 10 and 900 seconds");
|
|
1077
|
+
}
|
|
1078
|
+
return Math.round(seconds * 1000);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function resolveApprovalBridgeConfig() {
|
|
1082
|
+
const env = await readOptionalEnvFile(CONFIG_ENV_FILE);
|
|
1083
|
+
const publicBaseUrl = (
|
|
1084
|
+
process.env.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL ||
|
|
1085
|
+
envValue(env, "NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL") ||
|
|
1086
|
+
process.env.APPROVAL_SERVER_PUBLIC_BASE_URL ||
|
|
1087
|
+
envValue(env, "APPROVAL_SERVER_PUBLIC_BASE_URL") ||
|
|
1088
|
+
""
|
|
1089
|
+
).replace(/\/$/u, "");
|
|
1090
|
+
const localPort = process.env.NATIVE_APPROVAL_SERVER_PORT || envValue(env, "NATIVE_APPROVAL_SERVER_PORT") || "";
|
|
1091
|
+
const localProtocol = publicBaseUrl.startsWith("https:") ? "https" : "http";
|
|
1092
|
+
const baseUrl = (
|
|
1093
|
+
process.env.VIVEWORKER_APPROVAL_BRIDGE_URL ||
|
|
1094
|
+
(localPort ? `${localProtocol}://127.0.0.1:${localPort}` : publicBaseUrl)
|
|
1095
|
+
).replace(/\/$/u, "");
|
|
1096
|
+
const sessionSecret = (
|
|
1097
|
+
process.env.SESSION_SECRET ||
|
|
1098
|
+
envValue(env, "SESSION_SECRET") ||
|
|
1099
|
+
""
|
|
1100
|
+
).trim();
|
|
1101
|
+
return { baseUrl, sessionSecret, envText: env };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function readOptionalEnvFile(filePath) {
|
|
1105
|
+
try {
|
|
1106
|
+
return await fs.readFile(filePath, "utf8");
|
|
1107
|
+
} catch {
|
|
1108
|
+
return "";
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function postApprovalRequest(config, body, timeoutMs) {
|
|
1113
|
+
return postBridgeJson(config, "/api/payments/x402/approval", body, timeoutMs);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function postBridgeJson(config, pathname, body, timeoutMs) {
|
|
1117
|
+
const endpoint = `${config.baseUrl}${pathname}`;
|
|
1118
|
+
const payload = JSON.stringify(body);
|
|
1119
|
+
return new Promise((resolve) => {
|
|
1120
|
+
let resolved = false;
|
|
1121
|
+
const done = (value) => {
|
|
1122
|
+
if (resolved) return;
|
|
1123
|
+
resolved = true;
|
|
1124
|
+
resolve(value);
|
|
1125
|
+
};
|
|
1126
|
+
const timer = setTimeout(() => done({ ok: false, status: 0, body: { error: "approval-request-timeout" } }), timeoutMs);
|
|
1127
|
+
|
|
1128
|
+
let parsedUrl;
|
|
1129
|
+
try {
|
|
1130
|
+
parsedUrl = new URL(endpoint);
|
|
1131
|
+
} catch {
|
|
1132
|
+
clearTimeout(timer);
|
|
1133
|
+
done({ ok: false, status: 0, body: { error: "invalid-approval-server-url" } });
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const isHttps = parsedUrl.protocol === "https:";
|
|
1138
|
+
const port = parsedUrl.port ? Number(parsedUrl.port) : isHttps ? 443 : 80;
|
|
1139
|
+
const options = {
|
|
1140
|
+
hostname: parsedUrl.hostname,
|
|
1141
|
+
port,
|
|
1142
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
1143
|
+
method: "POST",
|
|
1144
|
+
headers: {
|
|
1145
|
+
"content-type": "application/json",
|
|
1146
|
+
"content-length": Buffer.byteLength(payload),
|
|
1147
|
+
"x-viveworker-hook-secret": config.sessionSecret,
|
|
1148
|
+
},
|
|
1149
|
+
// LAN approval servers often use mkcert/self-signed certificates.
|
|
1150
|
+
rejectUnauthorized: false,
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
const req = (isHttps ? https : http).request(options, (res) => {
|
|
1154
|
+
let text = "";
|
|
1155
|
+
res.on("data", (chunk) => { text += chunk; });
|
|
1156
|
+
res.on("end", () => {
|
|
1157
|
+
clearTimeout(timer);
|
|
1158
|
+
let parsed = {};
|
|
1159
|
+
try {
|
|
1160
|
+
parsed = text ? JSON.parse(text) : {};
|
|
1161
|
+
} catch {
|
|
1162
|
+
parsed = { error: text.slice(0, 200) };
|
|
1163
|
+
}
|
|
1164
|
+
done({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode || 0, body: parsed });
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
req.on("error", (error) => {
|
|
1168
|
+
clearTimeout(timer);
|
|
1169
|
+
done({ ok: false, status: 0, body: { error: error.message || "approval-request-failed" } });
|
|
1170
|
+
});
|
|
1171
|
+
req.write(payload);
|
|
1172
|
+
req.end();
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function assertApprovedPaymentMatches(expected, approved) {
|
|
1177
|
+
const keys = ["network", "chainId", "amountAtomic", "payTo", "asset", "resource"];
|
|
1178
|
+
for (const key of keys) {
|
|
1179
|
+
const left = normalizeComparablePaymentValue(expected?.[key]);
|
|
1180
|
+
const right = normalizeComparablePaymentValue(approved?.[key]);
|
|
1181
|
+
if (left && right && left !== right) {
|
|
1182
|
+
throw new Error(`Approved payment mismatch for ${key}; refusing to sign.`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function normalizeComparablePaymentValue(value) {
|
|
1188
|
+
return String(value ?? "").trim().toLowerCase();
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function resolveBuyerPrivateKey() {
|
|
1192
|
+
const raw = String(
|
|
1193
|
+
process.env.VIVEWORKER_BUYER_PRIVATE_KEY ||
|
|
1194
|
+
process.env.BUYER_PK ||
|
|
1195
|
+
""
|
|
1196
|
+
).trim();
|
|
1197
|
+
if (!raw) {
|
|
1198
|
+
throw new Error(
|
|
1199
|
+
"Missing buyer wallet private key. Set VIVEWORKER_BUYER_PRIVATE_KEY=0x... " +
|
|
1200
|
+
"or BUYER_PK=0x... for Base/Base Sepolia x402 payments."
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
return raw.startsWith("0x") ? raw : `0x${raw}`;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function buildXPaymentHeader(requirement, privateKey) {
|
|
1207
|
+
const ethers = await import("ethers");
|
|
1208
|
+
const network = SUPPORTED_BUYER_NETWORKS[String(requirement.network)];
|
|
1209
|
+
if (!network) throw new Error(`Unsupported buyer network: ${requirement.network}`);
|
|
1210
|
+
|
|
1211
|
+
const wallet = new ethers.Wallet(privateKey);
|
|
1212
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1213
|
+
const maxTimeout = Number(requirement.maxTimeoutSeconds || 60);
|
|
1214
|
+
const validAfter = String(Math.max(0, now - 30));
|
|
1215
|
+
const validBefore = String(now + Math.max(30, Math.min(300, Number.isFinite(maxTimeout) ? maxTimeout : 60)));
|
|
1216
|
+
const authorization = {
|
|
1217
|
+
from: wallet.address,
|
|
1218
|
+
to: ethers.getAddress(String(requirement.payTo)),
|
|
1219
|
+
value: String(requirement.maxAmountRequired),
|
|
1220
|
+
validAfter,
|
|
1221
|
+
validBefore,
|
|
1222
|
+
nonce: `0x${crypto.randomBytes(32).toString("hex")}`,
|
|
1223
|
+
};
|
|
1224
|
+
const domain = {
|
|
1225
|
+
name: String(requirement.extra?.name || network.usdcName),
|
|
1226
|
+
version: String(requirement.extra?.version || network.usdcVersion),
|
|
1227
|
+
chainId: network.chainId,
|
|
1228
|
+
verifyingContract: ethers.getAddress(String(requirement.asset)),
|
|
1229
|
+
};
|
|
1230
|
+
const types = {
|
|
1231
|
+
TransferWithAuthorization: [
|
|
1232
|
+
{ name: "from", type: "address" },
|
|
1233
|
+
{ name: "to", type: "address" },
|
|
1234
|
+
{ name: "value", type: "uint256" },
|
|
1235
|
+
{ name: "validAfter", type: "uint256" },
|
|
1236
|
+
{ name: "validBefore", type: "uint256" },
|
|
1237
|
+
{ name: "nonce", type: "bytes32" },
|
|
1238
|
+
],
|
|
1239
|
+
};
|
|
1240
|
+
const signature = await wallet.signTypedData(domain, types, authorization);
|
|
1241
|
+
const payload = {
|
|
1242
|
+
x402Version: X402_VERSION,
|
|
1243
|
+
scheme: String(requirement.scheme || "exact"),
|
|
1244
|
+
network: String(requirement.network),
|
|
1245
|
+
payload: {
|
|
1246
|
+
signature,
|
|
1247
|
+
authorization,
|
|
1248
|
+
},
|
|
1249
|
+
};
|
|
1250
|
+
return {
|
|
1251
|
+
header: Buffer.from(JSON.stringify(payload)).toString("base64"),
|
|
1252
|
+
payer: wallet.address,
|
|
1253
|
+
payload,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function decodePaymentResponseHeader(value) {
|
|
1258
|
+
const raw = String(value || "").trim();
|
|
1259
|
+
if (!raw) return null;
|
|
1260
|
+
try {
|
|
1261
|
+
const normalized = raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
1262
|
+
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
|
|
1263
|
+
return JSON.parse(Buffer.from(normalized + padding, "base64").toString("utf8"));
|
|
1264
|
+
} catch {
|
|
1265
|
+
return { raw };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
async function writeOrPreviewPaidBody(flags, res, bytes) {
|
|
1270
|
+
const contentType = res.headers.get("content-type") || "";
|
|
1271
|
+
if (flags.output) {
|
|
1272
|
+
const outputPath = path.resolve(String(flags.output));
|
|
1273
|
+
await fs.writeFile(outputPath, bytes);
|
|
1274
|
+
console.log(` saved: ${outputPath}`);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
previewPaidBody(contentType, bytes);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function previewPaidBody(contentType, bytes) {
|
|
1281
|
+
if (/^text\/|json|xml|csv|html/u.test(String(contentType).toLowerCase())) {
|
|
1282
|
+
const text = bytes.toString("utf8");
|
|
1283
|
+
console.log(text.length > 1200 ? `${text.slice(0, 1200)}\n...` : text);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
console.log(` received ${formatSize(bytes.length)} (${contentType || "unknown content type"}). Pass --output <file> to save bytes.`);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
739
1289
|
function formatApiError(op, status, body) {
|
|
740
1290
|
const code = body?.error || "";
|
|
741
1291
|
switch (code) {
|