@itpay/cli 0.2.17 → 2.0.0-rc.2
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 +148 -447
- package/bin/itp +1 -150
- package/dist/src/client/backend.js +154 -0
- package/dist/src/client/http.js +76 -0
- package/dist/src/client/types.js +4 -0
- package/dist/src/commands/buy.js +351 -0
- package/dist/src/commands/cart.js +264 -0
- package/dist/src/commands/catalog.js +26 -0
- package/dist/src/commands/checkout.js +106 -0
- package/dist/src/commands/docs.js +61 -0
- package/dist/src/commands/guidance.js +422 -0
- package/dist/src/commands/install.js +95 -0
- package/dist/src/commands/order.js +78 -0
- package/dist/src/commands/orders.js +22 -0
- package/dist/src/commands/pay.js +26 -0
- package/dist/src/commands/readyz.js +8 -0
- package/dist/src/commands/refund.js +20 -0
- package/dist/src/commands/services.js +317 -0
- package/dist/src/main.js +606 -0
- package/dist/src/render/feishu.js +201 -0
- package/dist/src/render/ide.js +321 -0
- package/dist/src/render/index.js +57 -0
- package/dist/src/render/interaction.js +49 -0
- package/dist/src/render/markdown.js +83 -0
- package/dist/src/render/output.js +42 -0
- package/dist/src/render/plain_chat.js +60 -0
- package/dist/src/render/plan.js +31 -0
- package/dist/src/render/qr.js +32 -0
- package/dist/src/render/sink.js +6 -0
- package/dist/src/render/status.js +37 -0
- package/dist/src/render/telegram.js +172 -0
- package/dist/src/render/terminal.js +148 -0
- package/dist/src/render/terminal_image.js +19 -0
- package/dist/src/state/cart_session.js +151 -0
- package/dist/src/state/client_context.js +73 -0
- package/dist/src/state/config.js +82 -0
- package/dist/src/state/device_authority.js +217 -0
- package/dist/src/state/operation_journal.js +80 -0
- package/docs/agent/buyer/cart-checkout.json +56 -94
- package/docs/agent/buyer/catalog-list.json +47 -0
- package/docs/agent/buyer/install-and-setup.json +82 -0
- package/docs/agent/buyer/orders-refunds.json +76 -0
- package/docs/agent/buyer/payment-flow.json +77 -0
- package/docs/agent/buyer/quickstart.json +143 -75
- package/docs/agent/buyer/render-hosts.json +79 -0
- package/package.json +32 -13
- package/skills/itpay-buyer/SKILL.md +107 -238
- package/docs/agent/buyer/account-portal.json +0 -81
- package/docs/agent/buyer/catalog-search.json +0 -106
- package/docs/agent/buyer/human-claim-ui.json +0 -77
- package/docs/agent/buyer/payment-qr.json +0 -97
- package/docs/agent/buyer/payment-wait.json +0 -84
- package/docs/agent/buyer/product-recommendation.json +0 -80
- package/docs/agent/buyer/qr-refresh.json +0 -67
- package/docs/agent/buyer/recovery.json +0 -85
- package/docs/agent/buyer/safety-policy.json +0 -70
- package/docs/agent/buyer/secure-delivery.json +0 -90
- package/docs/agent/buyer/vault-agent-read.json +0 -95
- package/install.ps1 +0 -65
- package/install.sh +0 -66
- package/lib/account-status.js +0 -157
- package/lib/buyer.js +0 -2332
- package/lib/client-context.js +0 -126
- package/lib/docs.js +0 -200
- package/lib/env.js +0 -723
- package/lib/http.js +0 -151
- package/lib/ops.js +0 -135
- package/lib/render-human.js +0 -718
- package/lib/runtime.js +0 -1456
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Cart session for the V3 CLI with file persistence. Server cart is the
|
|
2
|
+
// business source of truth; this file is a handle cache plus an explicit local
|
|
3
|
+
// draft compatibility layer. Do not rely on local items for Service Execution
|
|
4
|
+
// or quota facts.
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
export class CartSession {
|
|
9
|
+
state;
|
|
10
|
+
constructor(currency) {
|
|
11
|
+
this.state = { currency, items: [] };
|
|
12
|
+
}
|
|
13
|
+
static loadFromFile(path, currency) {
|
|
14
|
+
const session = new CartSession(currency);
|
|
15
|
+
if (existsSync(path)) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = readFileSync(path, "utf-8");
|
|
18
|
+
const persisted = JSON.parse(raw);
|
|
19
|
+
if (Array.isArray(persisted.items)) {
|
|
20
|
+
persisted.items.forEach((item) => {
|
|
21
|
+
if (item.catalogVariantID && item.offerID && item.quantity > 0) {
|
|
22
|
+
session.state.items.push({ ...item });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (persisted.agentDeviceID)
|
|
27
|
+
session.state.agentDeviceID = persisted.agentDeviceID;
|
|
28
|
+
if (persisted.lastCartID)
|
|
29
|
+
session.state.lastCartID = persisted.lastCartID;
|
|
30
|
+
if (persisted.lastCartItemID)
|
|
31
|
+
session.state.lastCartItemID = persisted.lastCartItemID;
|
|
32
|
+
if (persisted.lastServiceExecutionID)
|
|
33
|
+
session.state.lastServiceExecutionID = persisted.lastServiceExecutionID;
|
|
34
|
+
if (persisted.lastCheckoutID)
|
|
35
|
+
session.state.lastCheckoutID = persisted.lastCheckoutID;
|
|
36
|
+
if (persisted.lastDisplayToken)
|
|
37
|
+
session.state.lastDisplayToken = persisted.lastDisplayToken;
|
|
38
|
+
if (persisted.lastCheckoutURL)
|
|
39
|
+
session.state.lastCheckoutURL = persisted.lastCheckoutURL;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
session.state.items = [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return session;
|
|
46
|
+
}
|
|
47
|
+
saveToFile(path) {
|
|
48
|
+
const toSave = {
|
|
49
|
+
currency: this.state.currency,
|
|
50
|
+
items: this.state.items.map((item) => ({ ...item })),
|
|
51
|
+
...(this.state.agentDeviceID ? { agentDeviceID: this.state.agentDeviceID } : {}),
|
|
52
|
+
...(this.state.lastCartID ? { lastCartID: this.state.lastCartID } : {}),
|
|
53
|
+
...(this.state.lastCartItemID ? { lastCartItemID: this.state.lastCartItemID } : {}),
|
|
54
|
+
...(this.state.lastServiceExecutionID ? { lastServiceExecutionID: this.state.lastServiceExecutionID } : {}),
|
|
55
|
+
...(this.state.lastCheckoutID ? { lastCheckoutID: this.state.lastCheckoutID } : {}),
|
|
56
|
+
...(this.state.lastDisplayToken ? { lastDisplayToken: this.state.lastDisplayToken } : {}),
|
|
57
|
+
...(this.state.lastCheckoutURL ? { lastCheckoutURL: this.state.lastCheckoutURL } : {}),
|
|
58
|
+
};
|
|
59
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
60
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
61
|
+
writeFileSync(temporary, JSON.stringify(toSave, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
62
|
+
chmodSync(temporary, 0o600);
|
|
63
|
+
renameSync(temporary, path);
|
|
64
|
+
chmodSync(path, 0o600);
|
|
65
|
+
}
|
|
66
|
+
add(item) {
|
|
67
|
+
if (item.quantity <= 0) {
|
|
68
|
+
throw new Error("quantity must be > 0");
|
|
69
|
+
}
|
|
70
|
+
const existing = this.state.items.find((i) => i.catalogVariantID === item.catalogVariantID && i.offerID === item.offerID);
|
|
71
|
+
if (existing) {
|
|
72
|
+
existing.quantity += item.quantity;
|
|
73
|
+
if (item.input)
|
|
74
|
+
existing.input = item.input;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.state.items.push({ ...item });
|
|
78
|
+
}
|
|
79
|
+
remove(variantID, offerID) {
|
|
80
|
+
const before = this.state.items.length;
|
|
81
|
+
this.state.items = this.state.items.filter((i) => !(i.catalogVariantID === variantID && i.offerID === offerID));
|
|
82
|
+
if (this.state.items.length === before) {
|
|
83
|
+
throw new Error(`no line matches variant=${variantID} offer=${offerID}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
clear() {
|
|
87
|
+
this.state = {
|
|
88
|
+
currency: this.state.currency,
|
|
89
|
+
items: [],
|
|
90
|
+
...(this.state.agentDeviceID ? { agentDeviceID: this.state.agentDeviceID } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
show() {
|
|
94
|
+
return JSON.parse(JSON.stringify(this.state));
|
|
95
|
+
}
|
|
96
|
+
toCreateCartRequest() {
|
|
97
|
+
return {
|
|
98
|
+
currency: this.state.currency,
|
|
99
|
+
items: this.state.items.map((item) => ({
|
|
100
|
+
catalog_item_id: item.catalogItemID,
|
|
101
|
+
catalog_variant_id: item.catalogVariantID,
|
|
102
|
+
offer_id: item.offerID,
|
|
103
|
+
quantity: item.quantity,
|
|
104
|
+
...(item.input ? { input: item.input } : {}),
|
|
105
|
+
})),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
rememberCheckout(input) {
|
|
109
|
+
this.state.items = [];
|
|
110
|
+
this.state.lastCartID = input.cartID;
|
|
111
|
+
this.state.lastCheckoutID = input.checkoutID;
|
|
112
|
+
this.state.lastDisplayToken = input.displayToken;
|
|
113
|
+
this.state.lastCheckoutURL = input.checkoutURL;
|
|
114
|
+
if (input.serviceExecutionID)
|
|
115
|
+
this.state.lastServiceExecutionID = input.serviceExecutionID;
|
|
116
|
+
}
|
|
117
|
+
rememberServerCart(input) {
|
|
118
|
+
this.state.items = [];
|
|
119
|
+
this.state.lastCartID = input.cartID;
|
|
120
|
+
if (input.cartItemID)
|
|
121
|
+
this.state.lastCartItemID = input.cartItemID;
|
|
122
|
+
if (input.serviceExecutionID)
|
|
123
|
+
this.state.lastServiceExecutionID = input.serviceExecutionID;
|
|
124
|
+
if (input.agentDeviceID)
|
|
125
|
+
this.state.agentDeviceID = input.agentDeviceID;
|
|
126
|
+
}
|
|
127
|
+
ensureAgentDeviceID(fallback) {
|
|
128
|
+
if (!this.state.agentDeviceID) {
|
|
129
|
+
this.state.agentDeviceID = fallback;
|
|
130
|
+
}
|
|
131
|
+
return this.state.agentDeviceID;
|
|
132
|
+
}
|
|
133
|
+
get lastCartID() {
|
|
134
|
+
return this.state.lastCartID;
|
|
135
|
+
}
|
|
136
|
+
get lastCartItemID() {
|
|
137
|
+
return this.state.lastCartItemID;
|
|
138
|
+
}
|
|
139
|
+
get lastServiceExecutionID() {
|
|
140
|
+
return this.state.lastServiceExecutionID;
|
|
141
|
+
}
|
|
142
|
+
get lastCheckoutID() {
|
|
143
|
+
return this.state.lastCheckoutID;
|
|
144
|
+
}
|
|
145
|
+
get lastDisplayToken() {
|
|
146
|
+
return this.state.lastDisplayToken;
|
|
147
|
+
}
|
|
148
|
+
get currency() {
|
|
149
|
+
return this.state.currency;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// V3 CLI client-context: which host is the CLI running under, and what
|
|
2
|
+
// target (chat id, channel id, etc.) it has. Mirrors the role of V1's
|
|
3
|
+
// `lib/client-context.js` but kept narrow: no I/O, no env I/O beyond the
|
|
4
|
+
// raw values the caller passes in.
|
|
5
|
+
export const SUPPORTED_HOSTS = new Set([
|
|
6
|
+
"terminal",
|
|
7
|
+
"codex",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"telegram",
|
|
10
|
+
"discord",
|
|
11
|
+
"whatsapp",
|
|
12
|
+
"feishu",
|
|
13
|
+
"lark",
|
|
14
|
+
"plain-chat",
|
|
15
|
+
]);
|
|
16
|
+
// Hosts that have a real renderer attached. Discord / WhatsApp currently
|
|
17
|
+
// only have host validation and fall back to plain-chat, matching V1.
|
|
18
|
+
export const HOSTS_WITH_DEDICATED_RENDERER = new Set([
|
|
19
|
+
"terminal",
|
|
20
|
+
"codex",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"telegram",
|
|
23
|
+
"feishu",
|
|
24
|
+
"lark",
|
|
25
|
+
]);
|
|
26
|
+
// Telegram/Discord/WhatsApp/Feishu/Lark all require a stable target so we
|
|
27
|
+
// can route replies / callback answers back to the right chat.
|
|
28
|
+
export const HOSTS_REQUIRING_TARGET = new Set([
|
|
29
|
+
"telegram",
|
|
30
|
+
"discord",
|
|
31
|
+
"whatsapp",
|
|
32
|
+
"feishu",
|
|
33
|
+
"lark",
|
|
34
|
+
]);
|
|
35
|
+
const HOST_ALIASES = {
|
|
36
|
+
tg: "telegram",
|
|
37
|
+
"openclaw-telegram": "telegram",
|
|
38
|
+
trae: "codex",
|
|
39
|
+
"trae-agent": "codex",
|
|
40
|
+
feishu_im: "feishu",
|
|
41
|
+
fs: "feishu",
|
|
42
|
+
};
|
|
43
|
+
export function normalizeHost(raw) {
|
|
44
|
+
if (!raw)
|
|
45
|
+
return undefined;
|
|
46
|
+
const lower = raw.trim().toLowerCase();
|
|
47
|
+
if (SUPPORTED_HOSTS.has(lower))
|
|
48
|
+
return lower;
|
|
49
|
+
return HOST_ALIASES[lower];
|
|
50
|
+
}
|
|
51
|
+
export function requiresTarget(host) {
|
|
52
|
+
return HOSTS_REQUIRING_TARGET.has(host);
|
|
53
|
+
}
|
|
54
|
+
export function hasDedicatedRenderer(host) {
|
|
55
|
+
return HOSTS_WITH_DEDICATED_RENDERER.has(host);
|
|
56
|
+
}
|
|
57
|
+
export function defaultHostForAgentType(agentType) {
|
|
58
|
+
const normalized = agentType?.trim().toLowerCase() ?? "";
|
|
59
|
+
if (normalized.startsWith("codex"))
|
|
60
|
+
return "codex";
|
|
61
|
+
if (normalized.startsWith("claude-code"))
|
|
62
|
+
return "claude-code";
|
|
63
|
+
return "terminal";
|
|
64
|
+
}
|
|
65
|
+
export function validateContext(host, target) {
|
|
66
|
+
if (!host) {
|
|
67
|
+
return { code: "client_context_required", message: "--host is required" };
|
|
68
|
+
}
|
|
69
|
+
if (requiresTarget(host) && !target) {
|
|
70
|
+
return { code: "target_required", message: `--target is required for host ${host}` };
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// CLI configuration loader. Reads config from environment only. Checkout
|
|
2
|
+
// display-token persistence belongs to the cart session file, protected with
|
|
3
|
+
// owner-only permissions. Provider secrets are explicitly out of scope here.
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { HttpClient } from "../client/http.js";
|
|
8
|
+
import { BackendClient } from "../client/backend.js";
|
|
9
|
+
import { DeviceAuthority } from "./device_authority.js";
|
|
10
|
+
import { OperationJournal } from "./operation_journal.js";
|
|
11
|
+
export const DEFAULT_BASE_URL = "https://test.itpay.ai";
|
|
12
|
+
export const CLI_VERSION = "2.0.0-rc.2";
|
|
13
|
+
export const API_CONTRACT_REVISION = "sha256:2c2829f4618c47bc505efc0ded853cf639d775585ba13aa23012197e39efa31f";
|
|
14
|
+
const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
|
|
15
|
+
const CART_SESSION_FILENAME = "cart.json";
|
|
16
|
+
const OPERATION_JOURNAL_FILENAME = "operations.json";
|
|
17
|
+
export function cartSessionPath(env = process.env) {
|
|
18
|
+
if (env.ITPAY_CART_SESSION_PATH) {
|
|
19
|
+
return resolve(env.ITPAY_CART_SESSION_PATH);
|
|
20
|
+
}
|
|
21
|
+
const dir = resolve(homedir(), CART_SESSION_DEFAULT_DIR);
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
return resolve(dir, CART_SESSION_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
export function loadConfig(env = process.env) {
|
|
26
|
+
const baseURL = env.ITPAY_BACKEND_URL || DEFAULT_BASE_URL;
|
|
27
|
+
const bearerToken = env.ITPAY_BEARER_TOKEN || undefined;
|
|
28
|
+
const agentDeviceID = env.ITPAY_AGENT_DEVICE_ID || "";
|
|
29
|
+
const agentType = env.ITPAY_AGENT_TYPE || agentTypeFromArgv(process.argv);
|
|
30
|
+
const checkoutCurrency = env.ITPAY_CURRENCY || "CNY";
|
|
31
|
+
const idempotencyKey = env.ITPAY_IDEMPOTENCY_KEY || `cli_${shortRandom()}`;
|
|
32
|
+
const ideImageAttach = env.ITPAY_IDE_IMAGE_ATTACH !== "0";
|
|
33
|
+
const ideImageDirOverride = env.ITPAY_IDE_IMAGE_DIR_OVERRIDE;
|
|
34
|
+
return {
|
|
35
|
+
baseURL,
|
|
36
|
+
agentDeviceID,
|
|
37
|
+
...(agentType ? { agentType } : {}),
|
|
38
|
+
checkoutCurrency,
|
|
39
|
+
idempotencyKey,
|
|
40
|
+
...(!env.ITPAY_IDEMPOTENCY_KEY ? { operationJournal: new OperationJournal(resolve(homedir(), CART_SESSION_DEFAULT_DIR, OPERATION_JOURNAL_FILENAME)) } : {}),
|
|
41
|
+
ideImageAttach,
|
|
42
|
+
...(ideImageDirOverride ? { ideImageDirOverride } : {}),
|
|
43
|
+
...(bearerToken ? { bearerToken } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function operationID(config, operationKey) {
|
|
47
|
+
if (config.operationJournal)
|
|
48
|
+
return config.operationJournal.getOrCreate(operationKey);
|
|
49
|
+
return Promise.resolve(config.idempotencyKey);
|
|
50
|
+
}
|
|
51
|
+
export function newBackendClient(config) {
|
|
52
|
+
const authority = new DeviceAuthority({
|
|
53
|
+
baseURL: config.baseURL,
|
|
54
|
+
...(config.agentType ? { requestedAgentType: config.agentType } : {}),
|
|
55
|
+
compatibilityHeaders: {
|
|
56
|
+
"X-ItPay-CLI-Version": CLI_VERSION,
|
|
57
|
+
"X-ItPay-Contract-Revision": API_CONTRACT_REVISION,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const http = new HttpClient({
|
|
61
|
+
baseURL: config.baseURL,
|
|
62
|
+
defaultHeaders: {
|
|
63
|
+
"X-ItPay-CLI-Version": CLI_VERSION,
|
|
64
|
+
"X-ItPay-Contract-Revision": API_CONTRACT_REVISION,
|
|
65
|
+
},
|
|
66
|
+
requestAuthorizer: (input) => authority.authorizationHeaders(input),
|
|
67
|
+
});
|
|
68
|
+
return new BackendClient(http);
|
|
69
|
+
}
|
|
70
|
+
function agentTypeFromArgv(argv) {
|
|
71
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
72
|
+
const value = argv[index];
|
|
73
|
+
if (value === "--agent-type")
|
|
74
|
+
return argv[index + 1];
|
|
75
|
+
if (value?.startsWith("--agent-type="))
|
|
76
|
+
return value.slice("--agent-type=".length);
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
function shortRandom() {
|
|
81
|
+
return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
|
|
82
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign, } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
const PROTECTED_PATHS = ["/v1/carts", "/v1/service-executions", "/v1/agent-instances"];
|
|
6
|
+
export class DeviceAuthority {
|
|
7
|
+
baseURL;
|
|
8
|
+
requestedAgentType;
|
|
9
|
+
compatibilityHeaders;
|
|
10
|
+
statePath;
|
|
11
|
+
privateKeyPath;
|
|
12
|
+
fetchImpl;
|
|
13
|
+
pending;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.baseURL = options.baseURL.replace(/\/$/, "");
|
|
16
|
+
this.requestedAgentType = options.requestedAgentType;
|
|
17
|
+
this.compatibilityHeaders = options.compatibilityHeaders;
|
|
18
|
+
const root = resolve(homedir(), ".itpay-v3", "device");
|
|
19
|
+
this.statePath = options.statePath ?? resolve(root, "identity.json");
|
|
20
|
+
this.privateKeyPath = options.privateKeyPath ?? resolve(root, "device-private.pem");
|
|
21
|
+
this.fetchImpl = (options.fetchImpl ?? globalThis.fetch).bind(globalThis);
|
|
22
|
+
}
|
|
23
|
+
async authorizationHeaders(input) {
|
|
24
|
+
if (!PROTECTED_PATHS.some((prefix) => input.path.startsWith(prefix)))
|
|
25
|
+
return {};
|
|
26
|
+
const auth = await this.ensureAuthorization();
|
|
27
|
+
const timestamp = new Date().toISOString();
|
|
28
|
+
const jti = randomUUID();
|
|
29
|
+
const bodyHash = sha256(input.body);
|
|
30
|
+
const message = requestProofMessage(input.method, input.path, bodyHash, timestamp, jti);
|
|
31
|
+
const signature = sign(null, Buffer.from(message), auth.privateKey).toString("base64");
|
|
32
|
+
return {
|
|
33
|
+
Authorization: `ItPayDevice ${auth.session.token}`,
|
|
34
|
+
"X-ItPay-Agent-Instance-ID": auth.state.agentInstances[auth.agentType] ?? "",
|
|
35
|
+
"X-ItPay-Agent-Type": auth.agentType,
|
|
36
|
+
"X-ItPay-Agent-Timestamp": timestamp,
|
|
37
|
+
"X-ItPay-Agent-Proof-JTI": jti,
|
|
38
|
+
"X-ItPay-Agent-Body-SHA256": bodyHash,
|
|
39
|
+
"X-ItPay-Agent-Signature": signature,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async ensureAuthorization() {
|
|
43
|
+
if (!this.pending) {
|
|
44
|
+
this.pending = withFileLock(`${this.statePath}.lock`, () => this.prepareAuthorization()).finally(() => {
|
|
45
|
+
this.pending = undefined;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return this.pending;
|
|
49
|
+
}
|
|
50
|
+
async prepareAuthorization() {
|
|
51
|
+
let state = this.readState();
|
|
52
|
+
const agentType = this.requestedAgentType ?? firstAgentType(state);
|
|
53
|
+
if (!agentType) {
|
|
54
|
+
throw new Error("agent type is required for ItPay commerce; pass --agent-type <type> or set ITPAY_AGENT_TYPE");
|
|
55
|
+
}
|
|
56
|
+
let privateKey = this.readPrivateKey();
|
|
57
|
+
if (!state || !privateKey) {
|
|
58
|
+
const enrolled = await this.enroll(agentType);
|
|
59
|
+
state = enrolled.state;
|
|
60
|
+
privateKey = enrolled.privateKey;
|
|
61
|
+
}
|
|
62
|
+
if (!state.agentInstances[agentType]) {
|
|
63
|
+
const existingType = firstAgentType(state);
|
|
64
|
+
if (!existingType)
|
|
65
|
+
throw new Error("device has no registered agent instance");
|
|
66
|
+
const existingSession = await this.ensureSession(state, existingType, privateKey);
|
|
67
|
+
const registered = await this.signedJSON("/v1/agent-instances", { agent_type: agentType }, state, existingType, existingSession, privateKey);
|
|
68
|
+
state.agentInstances[agentType] = registered.agent_instance_id;
|
|
69
|
+
this.writeState(state);
|
|
70
|
+
}
|
|
71
|
+
const session = await this.ensureSession(state, agentType, privateKey);
|
|
72
|
+
return { state, agentType, session, privateKey };
|
|
73
|
+
}
|
|
74
|
+
async enroll(agentType) {
|
|
75
|
+
const pair = generateKeyPairSync("ed25519");
|
|
76
|
+
const publicJWK = pair.publicKey.export({ format: "jwk" });
|
|
77
|
+
if (!publicJWK.x)
|
|
78
|
+
throw new Error("unable to export Ed25519 public key");
|
|
79
|
+
const publicKey = Buffer.from(publicJWK.x, "base64url").toString("base64");
|
|
80
|
+
const started = await this.publicJSON("/v1/agent-device-enrollments", { public_key: publicKey, agent_type: agentType });
|
|
81
|
+
const proof = enrollmentProofMessage(started.agent_device_enrollment_id, started.challenge);
|
|
82
|
+
const verified = await this.publicJSON(`/v1/agent-device-enrollments/${encodeURIComponent(started.agent_device_enrollment_id)}/verify`, { challenge: started.challenge, signature: sign(null, Buffer.from(proof), pair.privateKey).toString("base64") });
|
|
83
|
+
const state = {
|
|
84
|
+
schemaVersion: "itpay.device.v1",
|
|
85
|
+
deviceID: verified.agent_device_id,
|
|
86
|
+
deviceKeyID: verified.agent_device_key_id,
|
|
87
|
+
quotaLineageID: verified.quota_lineage_id,
|
|
88
|
+
agentInstances: { [verified.agent_type]: verified.agent_instance_id },
|
|
89
|
+
sessions: {},
|
|
90
|
+
};
|
|
91
|
+
this.writePrivateKey(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString());
|
|
92
|
+
this.writeState(state);
|
|
93
|
+
return { state, privateKey: pair.privateKey };
|
|
94
|
+
}
|
|
95
|
+
async ensureSession(state, agentType, privateKey) {
|
|
96
|
+
const existing = state.sessions[agentType];
|
|
97
|
+
if (existing && Date.parse(existing.expiresAt) > Date.now() + 60_000)
|
|
98
|
+
return existing;
|
|
99
|
+
const instanceID = state.agentInstances[agentType];
|
|
100
|
+
if (!instanceID)
|
|
101
|
+
throw new Error(`agent instance is not registered for ${agentType}`);
|
|
102
|
+
const challenge = await this.publicJSON("/v1/agent-device-session-challenges", {
|
|
103
|
+
agent_device_id: state.deviceID,
|
|
104
|
+
agent_instance_id: instanceID,
|
|
105
|
+
});
|
|
106
|
+
const proof = deviceSessionProofMessage(challenge.agent_device_session_challenge_id, challenge.challenge);
|
|
107
|
+
const verified = await this.publicJSON(`/v1/agent-device-session-challenges/${encodeURIComponent(challenge.agent_device_session_challenge_id)}/verify`, { challenge: challenge.challenge, signature: sign(null, Buffer.from(proof), privateKey).toString("base64") });
|
|
108
|
+
const session = { token: verified.session_token, expiresAt: verified.expires_at };
|
|
109
|
+
state.sessions[agentType] = session;
|
|
110
|
+
this.writeState(state);
|
|
111
|
+
return session;
|
|
112
|
+
}
|
|
113
|
+
async signedJSON(path, bodyValue, state, agentType, session, privateKey) {
|
|
114
|
+
const body = JSON.stringify(bodyValue);
|
|
115
|
+
const timestamp = new Date().toISOString();
|
|
116
|
+
const jti = randomUUID();
|
|
117
|
+
const bodyHash = sha256(body);
|
|
118
|
+
const signature = sign(null, Buffer.from(requestProofMessage("POST", path, bodyHash, timestamp, jti)), privateKey).toString("base64");
|
|
119
|
+
return this.fetchJSON(path, body, {
|
|
120
|
+
Authorization: `ItPayDevice ${session.token}`,
|
|
121
|
+
"X-ItPay-Agent-Instance-ID": state.agentInstances[agentType] ?? "",
|
|
122
|
+
"X-ItPay-Agent-Type": agentType,
|
|
123
|
+
"X-ItPay-Agent-Timestamp": timestamp,
|
|
124
|
+
"X-ItPay-Agent-Proof-JTI": jti,
|
|
125
|
+
"X-ItPay-Agent-Body-SHA256": bodyHash,
|
|
126
|
+
"X-ItPay-Agent-Signature": signature,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
publicJSON(path, bodyValue) {
|
|
130
|
+
return this.fetchJSON(path, JSON.stringify(bodyValue), {});
|
|
131
|
+
}
|
|
132
|
+
async fetchJSON(path, body, extraHeaders) {
|
|
133
|
+
const response = await this.fetchImpl(this.baseURL + path, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", ...this.compatibilityHeaders, ...extraHeaders },
|
|
136
|
+
body,
|
|
137
|
+
});
|
|
138
|
+
const payload = await response.json().catch(() => ({}));
|
|
139
|
+
if (!response.ok)
|
|
140
|
+
throw new Error(payload.message || payload.code || `ItPay device request failed: ${response.status}`);
|
|
141
|
+
return payload;
|
|
142
|
+
}
|
|
143
|
+
readState() {
|
|
144
|
+
if (!existsSync(this.statePath))
|
|
145
|
+
return undefined;
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(readFileSync(this.statePath, "utf8"));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
readPrivateKey() {
|
|
154
|
+
if (!existsSync(this.privateKeyPath))
|
|
155
|
+
return undefined;
|
|
156
|
+
try {
|
|
157
|
+
return createPrivateKey(readFileSync(this.privateKeyPath, "utf8"));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
writeState(state) { atomicOwnerOnlyWrite(this.statePath, JSON.stringify(state, null, 2)); }
|
|
164
|
+
writePrivateKey(value) { atomicOwnerOnlyWrite(this.privateKeyPath, value); }
|
|
165
|
+
}
|
|
166
|
+
function firstAgentType(state) { return state ? Object.keys(state.agentInstances)[0] : undefined; }
|
|
167
|
+
function sha256(value) { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
|
|
168
|
+
function enrollmentProofMessage(id, challenge) { return `itpay-device-enrollment/v1\n${id}\n${challenge}`; }
|
|
169
|
+
function deviceSessionProofMessage(id, challenge) { return `itpay-device-session/v1\n${id}\n${challenge}`; }
|
|
170
|
+
function requestProofMessage(method, path, bodyHash, timestamp, jti) { return ["itpay-agent-request/v1", method, path, bodyHash, timestamp, jti].join("\n"); }
|
|
171
|
+
function atomicOwnerOnlyWrite(path, value) {
|
|
172
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
173
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
174
|
+
writeFileSync(temporary, value, { encoding: "utf8", mode: 0o600 });
|
|
175
|
+
chmodSync(temporary, 0o600);
|
|
176
|
+
renameSync(temporary, path);
|
|
177
|
+
chmodSync(path, 0o600);
|
|
178
|
+
}
|
|
179
|
+
async function withFileLock(path, run) {
|
|
180
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
181
|
+
let descriptor;
|
|
182
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
183
|
+
try {
|
|
184
|
+
descriptor = openSync(path, "wx", 0o600);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
const code = error.code;
|
|
189
|
+
if (code !== "EEXIST")
|
|
190
|
+
throw error;
|
|
191
|
+
try {
|
|
192
|
+
if (Date.now() - statSync(path).mtimeMs > 30_000)
|
|
193
|
+
unlinkSync(path);
|
|
194
|
+
}
|
|
195
|
+
catch (statError) {
|
|
196
|
+
if (statError.code !== "ENOENT")
|
|
197
|
+
throw statError;
|
|
198
|
+
}
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (descriptor === undefined)
|
|
203
|
+
throw new Error("timed out waiting for ItPay device identity lock");
|
|
204
|
+
try {
|
|
205
|
+
return await run();
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
closeSync(descriptor);
|
|
209
|
+
try {
|
|
210
|
+
unlinkSync(path);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.code !== "ENOENT")
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export class OperationJournal {
|
|
5
|
+
path;
|
|
6
|
+
constructor(path) {
|
|
7
|
+
this.path = path;
|
|
8
|
+
}
|
|
9
|
+
async getOrCreate(operationKey) {
|
|
10
|
+
return withFileLock(`${this.path}.lock`, async () => {
|
|
11
|
+
const state = this.read();
|
|
12
|
+
const existing = state.operations[operationKey];
|
|
13
|
+
if (existing)
|
|
14
|
+
return existing.id;
|
|
15
|
+
const id = `op_${randomUUID().replaceAll("-", "")}`;
|
|
16
|
+
state.operations[operationKey] = { id, createdAt: new Date().toISOString() };
|
|
17
|
+
atomicOwnerOnlyWrite(this.path, JSON.stringify(state, null, 2));
|
|
18
|
+
return id;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
read() {
|
|
22
|
+
if (existsSync(this.path)) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
25
|
+
if (parsed.schemaVersion === "itpay.operations.v1" && parsed.operations)
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A malformed local cache is replaced; server facts remain authoritative.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { schemaVersion: "itpay.operations.v1", operations: {} };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function atomicOwnerOnlyWrite(path, value) {
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
37
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
38
|
+
writeFileSync(temporary, value, { encoding: "utf8", mode: 0o600 });
|
|
39
|
+
chmodSync(temporary, 0o600);
|
|
40
|
+
renameSync(temporary, path);
|
|
41
|
+
chmodSync(path, 0o600);
|
|
42
|
+
}
|
|
43
|
+
async function withFileLock(path, run) {
|
|
44
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
|
+
let descriptor;
|
|
46
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
47
|
+
try {
|
|
48
|
+
descriptor = openSync(path, "wx", 0o600);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "EEXIST")
|
|
53
|
+
throw error;
|
|
54
|
+
try {
|
|
55
|
+
if (Date.now() - statSync(path).mtimeMs > 30_000)
|
|
56
|
+
unlinkSync(path);
|
|
57
|
+
}
|
|
58
|
+
catch (statError) {
|
|
59
|
+
if (statError.code !== "ENOENT")
|
|
60
|
+
throw statError;
|
|
61
|
+
}
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (descriptor === undefined)
|
|
66
|
+
throw new Error("timed out waiting for ItPay operation journal lock");
|
|
67
|
+
try {
|
|
68
|
+
return await run();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
closeSync(descriptor);
|
|
72
|
+
try {
|
|
73
|
+
unlinkSync(path);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code !== "ENOENT")
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|