@itpay/cli 2.0.3 → 2.0.7
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 +96 -142
- package/dist/src/client/backend.js +26 -8
- package/dist/src/client/http.js +29 -23
- package/dist/src/commands/buy.js +84 -132
- package/dist/src/commands/cart.js +274 -169
- package/dist/src/commands/catalog.js +64 -38
- package/dist/src/commands/checkout.js +128 -79
- package/dist/src/commands/docs.js +97 -51
- package/dist/src/commands/guidance.js +112 -16
- package/dist/src/commands/install.js +50 -87
- package/dist/src/commands/next.js +45 -0
- package/dist/src/commands/order.js +44 -69
- package/dist/src/commands/orders.js +43 -15
- package/dist/src/commands/pay.js +51 -22
- package/dist/src/commands/readyz.js +8 -4
- package/dist/src/commands/refund.js +132 -11
- package/dist/src/commands/services.js +799 -147
- package/dist/src/commands/skill.js +55 -0
- package/dist/src/main.js +820 -193
- package/dist/src/render/output.js +2 -3
- package/dist/src/state/agent_type.js +19 -0
- package/dist/src/state/cart_session.js +13 -17
- package/dist/src/state/client_context.js +4 -2
- package/dist/src/state/config.js +5 -15
- package/dist/src/state/device_authority.js +175 -57
- package/docs/agent/buyer/cart-checkout.json +27 -83
- package/docs/agent/buyer/catalog-list.json +2 -1
- package/docs/agent/buyer/identity-and-sessions.json +64 -0
- package/docs/agent/buyer/install-and-setup.json +35 -65
- package/docs/agent/buyer/orders-refunds.json +31 -53
- package/docs/agent/buyer/payment-flow.json +28 -57
- package/docs/agent/buyer/quickstart.json +46 -161
- package/docs/agent/buyer/render-hosts.json +43 -57
- package/docs/cli-reference/agent-types.md +51 -0
- package/docs/cli-reference/commands/buy.md +167 -0
- package/docs/cli-reference/commands/cart/add.md +86 -0
- package/docs/cli-reference/commands/cart/clear.md +53 -0
- package/docs/cli-reference/commands/cart/index.md +30 -0
- package/docs/cli-reference/commands/cart/next.md +71 -0
- package/docs/cli-reference/commands/cart/remove.md +53 -0
- package/docs/cli-reference/commands/cart/show.md +65 -0
- package/docs/cli-reference/commands/catalog/index.md +26 -0
- package/docs/cli-reference/commands/catalog/list.md +45 -0
- package/docs/cli-reference/commands/checkout.md +74 -0
- package/docs/cli-reference/commands/device.md +13 -0
- package/docs/cli-reference/commands/docs/index.md +28 -0
- package/docs/cli-reference/commands/docs/list.md +51 -0
- package/docs/cli-reference/commands/docs/search.md +69 -0
- package/docs/cli-reference/commands/docs/show.md +68 -0
- package/docs/cli-reference/commands/install.md +114 -0
- package/docs/cli-reference/commands/next.md +87 -0
- package/docs/cli-reference/commands/order.md +92 -0
- package/docs/cli-reference/commands/orders.md +83 -0
- package/docs/cli-reference/commands/pay.md +103 -0
- package/docs/cli-reference/commands/readyz.md +38 -0
- package/docs/cli-reference/commands/refund/cancel.md +62 -0
- package/docs/cli-reference/commands/refund/create.md +85 -0
- package/docs/cli-reference/commands/refund/get.md +60 -0
- package/docs/cli-reference/commands/refund/index.md +33 -0
- package/docs/cli-reference/commands/refund/list.md +68 -0
- package/docs/cli-reference/commands/refund/watch.md +73 -0
- package/docs/cli-reference/commands/services/action.md +48 -0
- package/docs/cli-reference/commands/services/checkout.md +82 -0
- package/docs/cli-reference/commands/services/events.md +73 -0
- package/docs/cli-reference/commands/services/get.md +66 -0
- package/docs/cli-reference/commands/services/index.md +45 -0
- package/docs/cli-reference/commands/services/invoke.md +67 -0
- package/docs/cli-reference/commands/services/list.md +61 -0
- package/docs/cli-reference/commands/services/next.md +181 -0
- package/docs/cli-reference/commands/services/quote.md +63 -0
- package/docs/cli-reference/commands/services/read-result.md +98 -0
- package/docs/cli-reference/commands/services/start.md +55 -0
- package/docs/cli-reference/commands/skill.md +17 -0
- package/docs/cli-reference/conventions.md +97 -0
- package/docs/cli-reference/index.md +65 -0
- package/package.json +1 -1
- package/skills/itpay-buyer/SKILL.md +71 -110
|
@@ -3,9 +3,6 @@ export function formatMoney(amountMinor, currency) {
|
|
|
3
3
|
const major = (amountMinor / 100).toFixed(2);
|
|
4
4
|
return `${major} ${currency}`;
|
|
5
5
|
}
|
|
6
|
-
export function renderReady(payload) {
|
|
7
|
-
return `backend ${payload.status} (version ${payload.version})`;
|
|
8
|
-
}
|
|
9
6
|
export function renderOrder(order) {
|
|
10
7
|
const lines = [];
|
|
11
8
|
lines.push(`order ${order.order_id}`);
|
|
@@ -36,6 +33,8 @@ export function renderRefund(refund) {
|
|
|
36
33
|
` status: ${refund.status}`,
|
|
37
34
|
` amount: ${formatMoney(refund.amount_minor, refund.currency)}`,
|
|
38
35
|
refund.reason ? ` reason: ${refund.reason}` : "",
|
|
36
|
+
` access: ${refund.access_locked ? "locked" : "available"}`,
|
|
37
|
+
` policy: ${refund.decision_mode === "automatic" ? "automatic" : "admin review"}`,
|
|
39
38
|
]
|
|
40
39
|
.filter((line) => line.length > 0)
|
|
41
40
|
.join("\n");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function declaredAgentType(env = process.env, argv = process.argv) {
|
|
2
|
+
if (env.ITPAY_AGENT_TYPE)
|
|
3
|
+
return env.ITPAY_AGENT_TYPE;
|
|
4
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
5
|
+
const value = argv[index];
|
|
6
|
+
if (value === "--agent-type")
|
|
7
|
+
return argv[index + 1];
|
|
8
|
+
if (value?.startsWith("--agent-type="))
|
|
9
|
+
return value.slice("--agent-type=".length);
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
export function qualifyItPayCommand(command, agentType) {
|
|
14
|
+
if (!agentType || !/^[a-z0-9-]+$/.test(agentType))
|
|
15
|
+
return command;
|
|
16
|
+
if (!command.startsWith("itpay ") || /^itpay\s+--agent-type(?:=|\s)/.test(command))
|
|
17
|
+
return command;
|
|
18
|
+
return `itpay --agent-type ${agentType} ${command.slice("itpay ".length)}`;
|
|
19
|
+
}
|
|
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
import { dirname } from "node:path";
|
|
8
8
|
export class CartSession {
|
|
9
9
|
state;
|
|
10
|
+
loadFailed = false;
|
|
10
11
|
constructor(currency) {
|
|
11
12
|
this.state = { currency, items: [] };
|
|
12
13
|
}
|
|
@@ -23,8 +24,6 @@ export class CartSession {
|
|
|
23
24
|
}
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
|
-
if (persisted.agentDeviceID)
|
|
27
|
-
session.state.agentDeviceID = persisted.agentDeviceID;
|
|
28
27
|
if (persisted.lastCartID)
|
|
29
28
|
session.state.lastCartID = persisted.lastCartID;
|
|
30
29
|
if (persisted.lastCartItemID)
|
|
@@ -40,6 +39,7 @@ export class CartSession {
|
|
|
40
39
|
}
|
|
41
40
|
catch {
|
|
42
41
|
session.state.items = [];
|
|
42
|
+
session.loadFailed = true;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
return session;
|
|
@@ -48,7 +48,6 @@ export class CartSession {
|
|
|
48
48
|
const toSave = {
|
|
49
49
|
currency: this.state.currency,
|
|
50
50
|
items: this.state.items.map((item) => ({ ...item })),
|
|
51
|
-
...(this.state.agentDeviceID ? { agentDeviceID: this.state.agentDeviceID } : {}),
|
|
52
51
|
...(this.state.lastCartID ? { lastCartID: this.state.lastCartID } : {}),
|
|
53
52
|
...(this.state.lastCartItemID ? { lastCartItemID: this.state.lastCartItemID } : {}),
|
|
54
53
|
...(this.state.lastServiceExecutionID ? { lastServiceExecutionID: this.state.lastServiceExecutionID } : {}),
|
|
@@ -84,11 +83,7 @@ export class CartSession {
|
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
clear() {
|
|
87
|
-
this.state = {
|
|
88
|
-
currency: this.state.currency,
|
|
89
|
-
items: [],
|
|
90
|
-
...(this.state.agentDeviceID ? { agentDeviceID: this.state.agentDeviceID } : {}),
|
|
91
|
-
};
|
|
86
|
+
this.state = { currency: this.state.currency, items: [] };
|
|
92
87
|
}
|
|
93
88
|
show() {
|
|
94
89
|
return JSON.parse(JSON.stringify(this.state));
|
|
@@ -107,7 +102,8 @@ export class CartSession {
|
|
|
107
102
|
}
|
|
108
103
|
rememberCheckout(input) {
|
|
109
104
|
this.state.items = [];
|
|
110
|
-
this.state.lastCartID
|
|
105
|
+
delete this.state.lastCartID;
|
|
106
|
+
delete this.state.lastCartItemID;
|
|
111
107
|
this.state.lastCheckoutID = input.checkoutID;
|
|
112
108
|
this.state.lastDisplayToken = input.displayToken;
|
|
113
109
|
this.state.lastCheckoutURL = input.checkoutURL;
|
|
@@ -117,18 +113,15 @@ export class CartSession {
|
|
|
117
113
|
rememberServerCart(input) {
|
|
118
114
|
this.state.items = [];
|
|
119
115
|
this.state.lastCartID = input.cartID;
|
|
116
|
+
delete this.state.lastCartItemID;
|
|
117
|
+
delete this.state.lastServiceExecutionID;
|
|
118
|
+
delete this.state.lastCheckoutID;
|
|
119
|
+
delete this.state.lastDisplayToken;
|
|
120
|
+
delete this.state.lastCheckoutURL;
|
|
120
121
|
if (input.cartItemID)
|
|
121
122
|
this.state.lastCartItemID = input.cartItemID;
|
|
122
123
|
if (input.serviceExecutionID)
|
|
123
124
|
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
125
|
}
|
|
133
126
|
get lastCartID() {
|
|
134
127
|
return this.state.lastCartID;
|
|
@@ -148,4 +141,7 @@ export class CartSession {
|
|
|
148
141
|
get currency() {
|
|
149
142
|
return this.state.currency;
|
|
150
143
|
}
|
|
144
|
+
get stateLoadFailed() {
|
|
145
|
+
return this.loadFailed;
|
|
146
|
+
}
|
|
151
147
|
}
|
|
@@ -56,10 +56,12 @@ export function hasDedicatedRenderer(host) {
|
|
|
56
56
|
}
|
|
57
57
|
export function defaultHostForAgentType(agentType) {
|
|
58
58
|
const normalized = agentType?.trim().toLowerCase() ?? "";
|
|
59
|
-
if (normalized
|
|
59
|
+
if (normalized === "codex-desktop")
|
|
60
60
|
return "codex";
|
|
61
|
-
if (normalized
|
|
61
|
+
if (normalized === "claude-code-desktop")
|
|
62
62
|
return "claude-code";
|
|
63
|
+
if (normalized === "workbuddy")
|
|
64
|
+
return "plain-chat";
|
|
63
65
|
return "terminal";
|
|
64
66
|
}
|
|
65
67
|
export function validateContext(host, target) {
|
package/dist/src/state/config.js
CHANGED
|
@@ -6,11 +6,12 @@ import { mkdirSync } from "node:fs";
|
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { HttpClient } from "../client/http.js";
|
|
8
8
|
import { BackendClient } from "../client/backend.js";
|
|
9
|
+
import { declaredAgentType } from "./agent_type.js";
|
|
9
10
|
import { DeviceAuthority } from "./device_authority.js";
|
|
10
11
|
import { OperationJournal } from "./operation_journal.js";
|
|
11
12
|
export const DEFAULT_BASE_URL = "https://app.itpay.ai";
|
|
12
|
-
export const CLI_VERSION = "2.0.
|
|
13
|
-
export const API_CONTRACT_REVISION = "sha256:
|
|
13
|
+
export const CLI_VERSION = "2.0.7";
|
|
14
|
+
export const API_CONTRACT_REVISION = "sha256:47d42ab7bbe74a806b9ec989384b28ad715ffcf05a5eb913449b8bc224ffcf49";
|
|
14
15
|
const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
|
|
15
16
|
const CART_SESSION_FILENAME = "cart.json";
|
|
16
17
|
const OPERATION_JOURNAL_FILENAME = "operations.json";
|
|
@@ -25,15 +26,13 @@ export function cartSessionPath(env = process.env) {
|
|
|
25
26
|
export function loadConfig(env = process.env) {
|
|
26
27
|
const baseURL = env.ITPAY_BACKEND_URL || DEFAULT_BASE_URL;
|
|
27
28
|
const bearerToken = env.ITPAY_BEARER_TOKEN || undefined;
|
|
28
|
-
const
|
|
29
|
-
const agentType = env.ITPAY_AGENT_TYPE || agentTypeFromArgv(process.argv);
|
|
29
|
+
const agentType = declaredAgentType(env);
|
|
30
30
|
const checkoutCurrency = env.ITPAY_CURRENCY || "CNY";
|
|
31
31
|
const idempotencyKey = env.ITPAY_IDEMPOTENCY_KEY || `cli_${shortRandom()}`;
|
|
32
32
|
const ideImageAttach = env.ITPAY_IDE_IMAGE_ATTACH !== "0";
|
|
33
33
|
const ideImageDirOverride = env.ITPAY_IDE_IMAGE_DIR_OVERRIDE;
|
|
34
34
|
return {
|
|
35
35
|
baseURL,
|
|
36
|
-
agentDeviceID,
|
|
37
36
|
...(agentType ? { agentType } : {}),
|
|
38
37
|
checkoutCurrency,
|
|
39
38
|
idempotencyKey,
|
|
@@ -64,19 +63,10 @@ export function newBackendClient(config) {
|
|
|
64
63
|
"X-ItPay-Contract-Revision": API_CONTRACT_REVISION,
|
|
65
64
|
},
|
|
66
65
|
requestAuthorizer: (input) => authority.authorizationHeaders(input),
|
|
66
|
+
recoverAuthorization: () => authority.recoverAuthorization(),
|
|
67
67
|
});
|
|
68
68
|
return new BackendClient(http);
|
|
69
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
70
|
function shortRandom() {
|
|
81
71
|
return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
|
|
82
72
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign, } from "node:crypto";
|
|
2
|
-
import { chmodSync,
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign, } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
-
const PROTECTED_PATHS = ["/v1/carts", "/v1/service-executions", "/v1/agent-instances"];
|
|
5
|
+
const PROTECTED_PATHS = ["/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds"];
|
|
6
6
|
export class DeviceAuthority {
|
|
7
7
|
baseURL;
|
|
8
|
+
backendKey;
|
|
8
9
|
requestedAgentType;
|
|
9
10
|
compatibilityHeaders;
|
|
10
11
|
statePath;
|
|
@@ -13,6 +14,7 @@ export class DeviceAuthority {
|
|
|
13
14
|
pending;
|
|
14
15
|
constructor(options) {
|
|
15
16
|
this.baseURL = options.baseURL.replace(/\/$/, "");
|
|
17
|
+
this.backendKey = normalizeBackendKey(options.baseURL);
|
|
16
18
|
this.requestedAgentType = options.requestedAgentType;
|
|
17
19
|
this.compatibilityHeaders = options.compatibilityHeaders;
|
|
18
20
|
const root = resolve(homedir(), ".itpay-v3", "device");
|
|
@@ -47,54 +49,93 @@ export class DeviceAuthority {
|
|
|
47
49
|
}
|
|
48
50
|
return this.pending;
|
|
49
51
|
}
|
|
52
|
+
async recoverAuthorization() {
|
|
53
|
+
await withFileLock(`${this.statePath}.lock`, async () => {
|
|
54
|
+
const state = this.readState();
|
|
55
|
+
if (!state || !this.requestedAgentType)
|
|
56
|
+
return;
|
|
57
|
+
const registration = state.registrations[this.backendKey];
|
|
58
|
+
if (!registration)
|
|
59
|
+
return;
|
|
60
|
+
delete registration.sessions[this.requestedAgentType];
|
|
61
|
+
this.writeState(state);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async recoverBackendReset() {
|
|
65
|
+
return withFileLock(`${this.statePath}.lock`, async () => {
|
|
66
|
+
const state = this.readState();
|
|
67
|
+
const registration = state?.registrations[this.backendKey];
|
|
68
|
+
if (!state || !registration)
|
|
69
|
+
return { removed: false, agentTypes: [] };
|
|
70
|
+
const agentTypes = Object.keys(registration.agentInstances).sort();
|
|
71
|
+
delete state.registrations[this.backendKey];
|
|
72
|
+
this.writeState(state);
|
|
73
|
+
return { removed: true, agentTypes };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
50
76
|
async prepareAuthorization() {
|
|
51
|
-
let state = this.readState();
|
|
52
|
-
const agentType = this.requestedAgentType
|
|
77
|
+
let state = this.readState() ?? emptyDeviceState();
|
|
78
|
+
const agentType = this.requestedAgentType;
|
|
53
79
|
if (!agentType) {
|
|
54
80
|
throw new Error("agent type is required for ItPay commerce; pass --agent-type <type> or set ITPAY_AGENT_TYPE");
|
|
55
81
|
}
|
|
56
82
|
let privateKey = this.readPrivateKey();
|
|
57
|
-
if (!
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
privateKey
|
|
83
|
+
if (!privateKey) {
|
|
84
|
+
const pair = generateKeyPairSync("ed25519");
|
|
85
|
+
privateKey = pair.privateKey;
|
|
86
|
+
this.writePrivateKey(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString());
|
|
87
|
+
state = emptyDeviceState();
|
|
61
88
|
}
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
let registration = state.registrations[this.backendKey];
|
|
90
|
+
if (!registration && state.legacyRegistration) {
|
|
91
|
+
try {
|
|
92
|
+
await this.ensureRegistrationAgentType(state.legacyRegistration, agentType, privateKey, true);
|
|
93
|
+
registration = state.legacyRegistration;
|
|
94
|
+
delete state.legacyRegistration;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!canMovePastLegacyRegistration(error))
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!registration) {
|
|
102
|
+
registration = await this.enroll(agentType, privateKey);
|
|
103
|
+
}
|
|
104
|
+
state.registrations[this.backendKey] = registration;
|
|
105
|
+
const session = await this.ensureRegistrationAgentType(registration, agentType, privateKey, false);
|
|
106
|
+
this.writeState(state);
|
|
107
|
+
return { state: registration, agentType, session, privateKey };
|
|
108
|
+
}
|
|
109
|
+
async ensureRegistrationAgentType(registration, agentType, privateKey, forceSession) {
|
|
110
|
+
if (!registration.agentInstances[agentType]) {
|
|
111
|
+
const existingType = firstAgentType(registration);
|
|
64
112
|
if (!existingType)
|
|
65
113
|
throw new Error("device has no registered agent instance");
|
|
66
|
-
const existingSession = await this.ensureSession(
|
|
67
|
-
const registered = await this.signedJSON("/v1/agent-instances", { agent_type: agentType },
|
|
68
|
-
|
|
69
|
-
this.writeState(state);
|
|
114
|
+
const existingSession = await this.ensureSession(registration, existingType, privateKey, forceSession);
|
|
115
|
+
const registered = await this.signedJSON("/v1/agent-instances", { agent_type: agentType }, registration, existingType, existingSession, privateKey);
|
|
116
|
+
registration.agentInstances[agentType] = registered.agent_instance_id;
|
|
70
117
|
}
|
|
71
|
-
|
|
72
|
-
return { state, agentType, session, privateKey };
|
|
118
|
+
return this.ensureSession(registration, agentType, privateKey, forceSession);
|
|
73
119
|
}
|
|
74
|
-
async enroll(agentType) {
|
|
75
|
-
const
|
|
76
|
-
const publicJWK = pair.publicKey.export({ format: "jwk" });
|
|
120
|
+
async enroll(agentType, privateKey) {
|
|
121
|
+
const publicJWK = createPublicKey(privateKey).export({ format: "jwk" });
|
|
77
122
|
if (!publicJWK.x)
|
|
78
123
|
throw new Error("unable to export Ed25519 public key");
|
|
79
124
|
const publicKey = Buffer.from(publicJWK.x, "base64url").toString("base64");
|
|
80
125
|
const started = await this.publicJSON("/v1/agent-device-enrollments", { public_key: publicKey, agent_type: agentType });
|
|
81
126
|
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),
|
|
83
|
-
|
|
84
|
-
schemaVersion: "itpay.device.v1",
|
|
127
|
+
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), privateKey).toString("base64") });
|
|
128
|
+
return {
|
|
85
129
|
deviceID: verified.agent_device_id,
|
|
86
130
|
deviceKeyID: verified.agent_device_key_id,
|
|
87
131
|
quotaLineageID: verified.quota_lineage_id,
|
|
88
132
|
agentInstances: { [verified.agent_type]: verified.agent_instance_id },
|
|
89
133
|
sessions: {},
|
|
90
134
|
};
|
|
91
|
-
this.writePrivateKey(pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString());
|
|
92
|
-
this.writeState(state);
|
|
93
|
-
return { state, privateKey: pair.privateKey };
|
|
94
135
|
}
|
|
95
|
-
async ensureSession(state, agentType, privateKey) {
|
|
136
|
+
async ensureSession(state, agentType, privateKey, force = false) {
|
|
96
137
|
const existing = state.sessions[agentType];
|
|
97
|
-
if (existing && Date.parse(existing.expiresAt) > Date.now() + 60_000)
|
|
138
|
+
if (!force && existing && Date.parse(existing.expiresAt) > Date.now() + 60_000)
|
|
98
139
|
return existing;
|
|
99
140
|
const instanceID = state.agentInstances[agentType];
|
|
100
141
|
if (!instanceID)
|
|
@@ -107,7 +148,6 @@ export class DeviceAuthority {
|
|
|
107
148
|
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
149
|
const session = { token: verified.session_token, expiresAt: verified.expires_at };
|
|
109
150
|
state.sessions[agentType] = session;
|
|
110
|
-
this.writeState(state);
|
|
111
151
|
return session;
|
|
112
152
|
}
|
|
113
153
|
async signedJSON(path, bodyValue, state, agentType, session, privateKey) {
|
|
@@ -137,16 +177,26 @@ export class DeviceAuthority {
|
|
|
137
177
|
});
|
|
138
178
|
const payload = await response.json().catch(() => ({}));
|
|
139
179
|
if (!response.ok)
|
|
140
|
-
throw new
|
|
180
|
+
throw new DeviceAuthorizationError(response.status, payload.code, payload.message || payload.code || `ItPay device request failed: ${response.status}`);
|
|
141
181
|
return payload;
|
|
142
182
|
}
|
|
143
183
|
readState() {
|
|
144
184
|
if (!existsSync(this.statePath))
|
|
145
185
|
return undefined;
|
|
146
186
|
try {
|
|
147
|
-
|
|
187
|
+
const parsed = JSON.parse(readFileSync(this.statePath, "utf8"));
|
|
188
|
+
if (parsed.schemaVersion === "itpay.device.v2")
|
|
189
|
+
return parsed;
|
|
190
|
+
if (parsed.schemaVersion === "itpay.device.v1") {
|
|
191
|
+
const { schemaVersion: _, ...legacyRegistration } = parsed;
|
|
192
|
+
return { ...emptyDeviceState(), legacyRegistration };
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
148
195
|
}
|
|
149
|
-
catch {
|
|
196
|
+
catch (error) {
|
|
197
|
+
const stateError = asDeviceStateError(error, "read_state");
|
|
198
|
+
if (stateError)
|
|
199
|
+
throw stateError;
|
|
150
200
|
return undefined;
|
|
151
201
|
}
|
|
152
202
|
}
|
|
@@ -156,62 +206,130 @@ export class DeviceAuthority {
|
|
|
156
206
|
try {
|
|
157
207
|
return createPrivateKey(readFileSync(this.privateKeyPath, "utf8"));
|
|
158
208
|
}
|
|
159
|
-
catch {
|
|
209
|
+
catch (error) {
|
|
210
|
+
const stateError = asDeviceStateError(error, "read_private_key");
|
|
211
|
+
if (stateError)
|
|
212
|
+
throw stateError;
|
|
160
213
|
return undefined;
|
|
161
214
|
}
|
|
162
215
|
}
|
|
163
|
-
writeState(state) { atomicOwnerOnlyWrite(this.statePath, JSON.stringify(state, null, 2)); }
|
|
164
|
-
writePrivateKey(value) { atomicOwnerOnlyWrite(this.privateKeyPath, value); }
|
|
216
|
+
writeState(state) { atomicOwnerOnlyWrite(this.statePath, JSON.stringify(state, null, 2), "write_state"); }
|
|
217
|
+
writePrivateKey(value) { atomicOwnerOnlyWrite(this.privateKeyPath, value, "write_private_key"); }
|
|
218
|
+
}
|
|
219
|
+
export class DeviceAuthorizationError extends Error {
|
|
220
|
+
status;
|
|
221
|
+
code;
|
|
222
|
+
constructor(status, code, message) {
|
|
223
|
+
super(message);
|
|
224
|
+
this.status = status;
|
|
225
|
+
this.code = code;
|
|
226
|
+
this.name = "DeviceAuthorizationError";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export class DeviceStateError extends Error {
|
|
230
|
+
operation;
|
|
231
|
+
causeCode;
|
|
232
|
+
code = "device_state_unwritable";
|
|
233
|
+
constructor(operation, causeCode) {
|
|
234
|
+
super(`ItPay device state operation failed: ${operation} (${causeCode})`);
|
|
235
|
+
this.operation = operation;
|
|
236
|
+
this.causeCode = causeCode;
|
|
237
|
+
this.name = "DeviceStateError";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function emptyDeviceState() {
|
|
241
|
+
return { schemaVersion: "itpay.device.v2", registrations: {} };
|
|
242
|
+
}
|
|
243
|
+
function firstAgentType(state) { return Object.keys(state.agentInstances)[0]; }
|
|
244
|
+
function canMovePastLegacyRegistration(error) {
|
|
245
|
+
return error instanceof DeviceAuthorizationError && (error.code === "agent_device_revoked" || error.status === 404);
|
|
246
|
+
}
|
|
247
|
+
function normalizeBackendKey(value) {
|
|
248
|
+
const url = new URL(value);
|
|
249
|
+
url.search = "";
|
|
250
|
+
url.hash = "";
|
|
251
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
252
|
+
return url.toString().replace(/\/$/, "");
|
|
165
253
|
}
|
|
166
|
-
function firstAgentType(state) { return state ? Object.keys(state.agentInstances)[0] : undefined; }
|
|
167
254
|
function sha256(value) { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
|
|
168
255
|
function enrollmentProofMessage(id, challenge) { return `itpay-device-enrollment/v1\n${id}\n${challenge}`; }
|
|
169
256
|
function deviceSessionProofMessage(id, challenge) { return `itpay-device-session/v1\n${id}\n${challenge}`; }
|
|
170
257
|
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 });
|
|
258
|
+
function atomicOwnerOnlyWrite(path, value, operation) {
|
|
173
259
|
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
260
|
+
try {
|
|
261
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
262
|
+
writeFileSync(temporary, value, { encoding: "utf8", mode: 0o600 });
|
|
263
|
+
chmodSync(temporary, 0o600);
|
|
264
|
+
renameSync(temporary, path);
|
|
265
|
+
chmodSync(path, 0o600);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
try {
|
|
269
|
+
unlinkSync(temporary);
|
|
270
|
+
}
|
|
271
|
+
catch { /* best-effort cleanup */ }
|
|
272
|
+
throw asDeviceStatePathError(error, operation) ?? error;
|
|
273
|
+
}
|
|
178
274
|
}
|
|
179
275
|
async function withFileLock(path, run) {
|
|
180
|
-
|
|
181
|
-
|
|
276
|
+
try {
|
|
277
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
throw asDeviceStatePathError(error, "prepare_lock") ?? error;
|
|
281
|
+
}
|
|
282
|
+
let acquired = false;
|
|
182
283
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
183
284
|
try {
|
|
184
|
-
|
|
285
|
+
mkdirSync(path, { mode: 0o700 });
|
|
286
|
+
acquired = true;
|
|
185
287
|
break;
|
|
186
288
|
}
|
|
187
289
|
catch (error) {
|
|
188
290
|
const code = error.code;
|
|
189
291
|
if (code !== "EEXIST")
|
|
190
|
-
throw error;
|
|
292
|
+
throw asDeviceStateError(error, "acquire_lock") ?? error;
|
|
191
293
|
try {
|
|
192
294
|
if (Date.now() - statSync(path).mtimeMs > 30_000)
|
|
193
|
-
|
|
295
|
+
removeLock(path, "remove_stale_lock");
|
|
194
296
|
}
|
|
195
297
|
catch (statError) {
|
|
196
|
-
if (statError.code !== "ENOENT")
|
|
197
|
-
throw statError;
|
|
298
|
+
if (statError.code !== "ENOENT") {
|
|
299
|
+
throw asDeviceStateError(statError, "inspect_lock") ?? statError;
|
|
300
|
+
}
|
|
198
301
|
}
|
|
199
302
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
200
303
|
}
|
|
201
304
|
}
|
|
202
|
-
if (
|
|
305
|
+
if (!acquired)
|
|
203
306
|
throw new Error("timed out waiting for ItPay device identity lock");
|
|
204
307
|
try {
|
|
205
308
|
return await run();
|
|
206
309
|
}
|
|
207
310
|
finally {
|
|
208
|
-
|
|
209
|
-
|
|
311
|
+
removeLock(path, "release_lock");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function removeLock(path, operation) {
|
|
315
|
+
try {
|
|
316
|
+
if (statSync(path).isDirectory())
|
|
317
|
+
rmdirSync(path);
|
|
318
|
+
else
|
|
210
319
|
unlinkSync(path);
|
|
211
|
-
}
|
|
212
|
-
catch (error) {
|
|
213
|
-
if (error.code !== "ENOENT")
|
|
214
|
-
throw error;
|
|
215
|
-
}
|
|
216
320
|
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (error.code !== "ENOENT")
|
|
323
|
+
throw asDeviceStateError(error, operation) ?? error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function asDeviceStateError(error, operation) {
|
|
327
|
+
const code = error.code;
|
|
328
|
+
return code === "EACCES" || code === "EPERM" || code === "EROFS" || code === "ENOTDIR" || code === "EISDIR"
|
|
329
|
+
? new DeviceStateError(operation, code)
|
|
330
|
+
: undefined;
|
|
331
|
+
}
|
|
332
|
+
function asDeviceStatePathError(error, operation) {
|
|
333
|
+
const code = error.code;
|
|
334
|
+
return code === "EEXIST" ? new DeviceStateError(operation, code) : asDeviceStateError(error, operation);
|
|
217
335
|
}
|