@itpay/cli 2.0.40 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/sell-preview/index.html +6 -0
- package/assets/sell-preview/preview.js +9 -0
- package/bin/darwin-amd64/itpay-sell +0 -0
- package/bin/darwin-arm64/itpay-sell +0 -0
- package/bin/linux-amd64/itpay-sell +0 -0
- package/bin/linux-arm64/itpay-sell +0 -0
- package/bin/windows-amd64/itpay-sell.exe +0 -0
- package/dist/src/client/backend.js +12 -0
- package/dist/src/commands/services.js +19 -4
- package/dist/src/main.js +16 -0
- package/dist/src/sell/commands.js +80 -0
- package/dist/src/sell/contract.js +84 -0
- package/dist/src/sell/local.js +260 -0
- package/dist/src/sell/mcp.js +49 -0
- package/dist/src/sell/preview.js +70 -0
- package/dist/src/sell/sync.js +145 -0
- package/dist/src/state/account_auth.js +90 -0
- package/dist/src/state/config.js +31 -12
- package/dist/src/state/device_authority.js +1 -1
- package/docs/cli-reference/commands/auth.md +9 -0
- package/docs/cli-reference/commands/sell.md +1342 -0
- package/docs/cli-reference/index.md +6 -1
- package/docs/sell.md +56 -0
- package/package.json +10 -6
- package/skills/itpay/SKILL.md +9 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { SELL_GUIDE, SELL_OPERATIONS, sellRequest } from './contract.js';
|
|
5
|
+
import { LOCAL_ACTIONS, localAction } from './local.js';
|
|
6
|
+
import { newBackendClient, loadConfig } from '../state/config.js';
|
|
7
|
+
import { syncProject } from './sync.js';
|
|
8
|
+
import { preview } from './preview.js';
|
|
9
|
+
export async function serveSellMCP(directory) {
|
|
10
|
+
const server = new McpServer({ name: 'itpay-sell-local', version: SELL_GUIDE.version }, { instructions: 'Use itpay_seller_guide first. Local tests call Provider APIs and may incur cost; disclose side effects and obtain user approval. Never pass secret values in tool arguments. This server uses its configured project directory.' });
|
|
11
|
+
server.tool('itpay_seller_guide', 'Read the publishing guide and constraints', {}, async () => ({ content: [{ type: 'text', text: JSON.stringify({ ...SELL_GUIDE, operations: SELL_OPERATIONS }) }] }));
|
|
12
|
+
for (const action of LOCAL_ACTIONS) {
|
|
13
|
+
server.tool('itpay_seller_local_' + action.replaceAll(' ', '_').replaceAll('-', '_'), `Local ${action}. Paths are user-provided files; never scan for credentials. Confirm only after explicit user approval.`, { merchant_id: z.string().optional(), environment: z.string().optional(), service_id: z.string().optional(), url: z.string().url().optional(), file: z.string().optional(), name: z.string().optional(), provider_key: z.string().optional(), profile: z.string().optional(), version: z.string().optional(), run: z.string().optional(), confirmed: z.boolean().default(false) }, async (args) => {
|
|
14
|
+
try {
|
|
15
|
+
const result = await localAction(action, directory, { ...args, merchantId: args.merchant_id, providerKey: args.provider_key, serviceId: args.service_id, confirm: args.confirmed });
|
|
16
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Local action failed' }] };
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
server.tool('itpay_seller_sync', 'Upload or download the configured project after explicit user confirmation. Never pass credential values.', { action: z.enum(['push', 'pull']), merchant_id: z.string(), draft_id: z.string().optional(), bindings_file: z.string().optional(), confirmed: z.boolean().default(false) }, async (args) => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await syncProject(args.action, directory, { merchantId: args.merchant_id, draftId: args.draft_id, bindings: args.bindings_file, confirm: args.confirmed });
|
|
26
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Sync failed' }] };
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
let display;
|
|
33
|
+
server.tool('itpay_seller_preview', 'Open the existing Builder for this local workflow; user can then confirm through the local workflow tool.', {}, async () => { display ??= await preview(directory); return { content: [{ type: 'text', text: JSON.stringify({ url: display.url }) }] }; });
|
|
34
|
+
for (const operation of SELL_OPERATIONS) {
|
|
35
|
+
server.tool('itpay_seller_platform_' + operation.command.replaceAll(' ', '_').replaceAll('-', '_'), `${operation.description}. Input contract: ${JSON.stringify(operation.fields)}. Optional fields: ${JSON.stringify(operation.optional ?? [])}.`, { merchant_id: z.string().optional(), draft_id: z.string().optional(), library_api_id: z.string().optional(), intake_id: z.string().optional(), version_id: z.string().optional(), run_id: z.string().optional(), submission_id: z.string().optional(), input: z.record(z.unknown()).default({}), confirmed: z.boolean().default(false) }, async (args) => {
|
|
36
|
+
try {
|
|
37
|
+
if (operation.confirmation && !args.confirmed)
|
|
38
|
+
throw new Error('Explicit user confirmation required');
|
|
39
|
+
const result = await newBackendClient(loadConfig()).sellRequest(sellRequest(operation, args, args.input));
|
|
40
|
+
return { content: [{ type: 'text', text: JSON.stringify(result ?? {}) }] };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Platform action failed' }] };
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
process.stdin.on('end', () => display?.close());
|
|
48
|
+
await server.connect(new StdioServerTransport());
|
|
49
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolve, extname } from 'node:path';
|
|
6
|
+
import { project } from './local.js';
|
|
7
|
+
export async function preview(directory) {
|
|
8
|
+
const packaged = fileURLToPath(new URL('../../../assets/sell-preview/', import.meta.url));
|
|
9
|
+
const assets = existsSync(packaged) ? packaged : fileURLToPath(new URL('../../assets/sell-preview/', import.meta.url));
|
|
10
|
+
if (!existsSync(resolve(assets, 'index.html')))
|
|
11
|
+
throw new Error('CLI preview assets are missing; install the complete CLI package');
|
|
12
|
+
const token = randomBytes(24).toString('hex');
|
|
13
|
+
const server = createServer((req, res) => {
|
|
14
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
15
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
16
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
17
|
+
if (req.method !== 'GET' || !req.url?.startsWith('/' + token + '/')) {
|
|
18
|
+
res.writeHead(404).end();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const path = req.url.slice(token.length + 2).split('?')[0] ?? '';
|
|
23
|
+
if (path === 'document') {
|
|
24
|
+
const p = project(directory);
|
|
25
|
+
const versions = JSON.parse(readFileSync(resolve(p.state, 'versions.json'), 'utf8'));
|
|
26
|
+
const version = versions.versions.find((v) => v.version_id === versions.selected);
|
|
27
|
+
res.setHeader('Content-Type', 'application/json');
|
|
28
|
+
const actualHash = 'sha256:' + createHash('sha256').update(p.yaml).digest('hex');
|
|
29
|
+
let run;
|
|
30
|
+
const marker = resolve(p.state, 'last-run.json');
|
|
31
|
+
if (existsSync(marker)) {
|
|
32
|
+
const id = JSON.parse(readFileSync(marker, 'utf8')).run_id;
|
|
33
|
+
if (typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id) && existsSync(resolve(p.state, 'runs', id, 'meta.json'))) {
|
|
34
|
+
const dir = resolve(p.state, 'runs', id), meta = JSON.parse(readFileSync(resolve(dir, 'meta.json'), 'utf8'));
|
|
35
|
+
const traces = existsSync(resolve(dir, 'nodes.json')) ? JSON.parse(readFileSync(resolve(dir, 'nodes.json'), 'utf8')) : [];
|
|
36
|
+
const report = existsSync(resolve(dir, 'report.json')) ? JSON.parse(readFileSync(resolve(dir, 'report.json'), 'utf8')).report : undefined;
|
|
37
|
+
const configHash = 'sha256:' + createHash('sha256').update(JSON.stringify(p.config)).digest('hex');
|
|
38
|
+
const stale = meta.hash !== actualHash || meta.config_hash !== configHash;
|
|
39
|
+
const fixtures = p.config.fixtures ?? [];
|
|
40
|
+
run = { status: stale ? 'stale' : report?.status ?? 'running', issues: report?.issues ?? [], validation: { fixture_runs: fixtures.map((f) => ({ ...f, nodes: traces.filter((t) => t.record_type === 'node' && t.fixture_id === f.fixture_id) })), api_recognitions: [], side_effect_operations: [] } };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
res.end(JSON.stringify({ document: p.yaml, version: version?.hash === actualHash ? version.name : 'Unsaved', hash: actualHash, pricing: p.config.pricing, run }));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const file = resolve(assets, path || 'index.html');
|
|
47
|
+
if (!file.startsWith(resolve(assets) + '/')) {
|
|
48
|
+
res.writeHead(404).end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };
|
|
52
|
+
res.setHeader('Content-Type', types[extname(file)] ?? 'application/octet-stream');
|
|
53
|
+
res.end(readFileSync(file));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
res.writeHead(404).end();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return new Promise((done, fail) => {
|
|
60
|
+
server.on('error', fail);
|
|
61
|
+
server.listen(0, '127.0.0.1', () => {
|
|
62
|
+
const address = server.address();
|
|
63
|
+
if (!address || typeof address === 'string') {
|
|
64
|
+
fail(new Error('Preview address unavailable'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
done({ url: `http://127.0.0.1:${address.port}/${token}/`, close: () => server.close() });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { HttpError } from "../client/http.js";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { newBackendClient, loadConfig } from '../state/config.js';
|
|
6
|
+
import { companion, project, localDependencies } from './local.js';
|
|
7
|
+
const encode = encodeURIComponent;
|
|
8
|
+
const hash = (value) => 'sha256:' + createHash('sha256').update(value).digest('hex');
|
|
9
|
+
function read(path) { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
10
|
+
function save(path, value) { writeFileSync(path, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); }
|
|
11
|
+
function platformWorkflow(draft) {
|
|
12
|
+
const document = draft.arazzo?.arazzo_document;
|
|
13
|
+
if (typeof document !== 'string' || !document.trim())
|
|
14
|
+
throw new Error('Platform response is missing the workflow document');
|
|
15
|
+
return document;
|
|
16
|
+
}
|
|
17
|
+
export async function syncProject(action, directory, options) {
|
|
18
|
+
const p = project(directory), cloudPath = join(p.state, 'cloud.json');
|
|
19
|
+
let cloud = existsSync(cloudPath) ? read(cloudPath) : {};
|
|
20
|
+
const merchant = options.merchantId ?? cloud.merchant_id ?? p.config.merchant_id;
|
|
21
|
+
if (!merchant)
|
|
22
|
+
throw new Error('--merchant-id is required');
|
|
23
|
+
if (cloud.merchant_id && cloud.merchant_id !== merchant)
|
|
24
|
+
throw new Error('Project is bound to a different merchant');
|
|
25
|
+
const api = newBackendClient(loadConfig()), org = '/v1/seller/organizations/' + encode(merchant);
|
|
26
|
+
const call = async (method, path, body) => await api.sellRequest({ method, path, ...(body !== undefined ? { body } : {}) });
|
|
27
|
+
if (action === 'pull') {
|
|
28
|
+
const draftID = options.draftId ?? cloud.draft_id;
|
|
29
|
+
if (!draftID)
|
|
30
|
+
throw new Error('--draft-id is required');
|
|
31
|
+
if (!options.confirm)
|
|
32
|
+
throw new Error('Pull replaces local workflow/settings; inspect changes and confirm first');
|
|
33
|
+
const bundle = await call('GET', `${org}/service-drafts/${encode(draftID)}/bundle`);
|
|
34
|
+
const backup = join(p.state, 'backups', String(Date.now()));
|
|
35
|
+
mkdirSync(backup, { recursive: true, mode: 0o700 });
|
|
36
|
+
writeFileSync(join(backup, 'workflow.yaml'), p.yaml);
|
|
37
|
+
save(join(backup, 'service.json'), p.config);
|
|
38
|
+
const draft = bundle.draft;
|
|
39
|
+
writeFileSync(join(p.root, 'workflow.yaml'), platformWorkflow(draft));
|
|
40
|
+
save(join(p.root, 'service.json'), { ...p.config, service_id: draft.service_id, public_name: draft.public_name, pricing: draft.pricing, policy: draft.policy, fixtures: bundle.fixtures.fixtures });
|
|
41
|
+
save(join(p.state, 'platform-dependencies.json'), bundle.dependencies);
|
|
42
|
+
cloud = { merchant_id: merchant, draft_id: draftID, revision: draft.semantic_revision, hash: draft.semantic_hash };
|
|
43
|
+
save(cloudPath, cloud);
|
|
44
|
+
return { cloud, backup, next: 'Save a new local version. Platform credentials were not downloaded.' };
|
|
45
|
+
}
|
|
46
|
+
const versions = read(join(p.state, 'versions.json'));
|
|
47
|
+
const version = versions.versions.find((v) => v.version_id === versions.selected);
|
|
48
|
+
const confirmation = existsSync(join(p.state, 'confirmation.json')) ? read(join(p.state, 'confirmation.json')) : {};
|
|
49
|
+
if (!version || version.hash !== hash(p.yaml) || version.config_hash !== hash(JSON.stringify(p.config)) || confirmation.version_id !== version.version_id || confirmation.hash !== version.hash || confirmation.config_hash !== version.config_hash)
|
|
50
|
+
throw new Error('Save and confirm the exact current version before uploading');
|
|
51
|
+
const validation = (await companion({ action: 'validate', dependencies: localDependencies(p.state), document: p.yaml, pricing: p.config.pricing, policy: p.config.policy })).find(event => event.type === 'validation')?.value;
|
|
52
|
+
if (!validation?.result?.valid)
|
|
53
|
+
throw new Error('Workflow does not pass validation');
|
|
54
|
+
if (!options.confirm)
|
|
55
|
+
return { local_version: version, cloud, pricing: p.config.pricing, instruction: 'Review the service package and platform API calls, then confirm upload. Platform verification is a separate explicit action.' };
|
|
56
|
+
if (!cloud.draft_id) {
|
|
57
|
+
const drafts = await call('GET', `${org}/service-drafts`);
|
|
58
|
+
const existing = drafts.drafts?.find((d) => d.service_id === p.config.service_id);
|
|
59
|
+
if (existing)
|
|
60
|
+
throw new Error('Service ID already exists. Pull that draft explicitly before updating it.');
|
|
61
|
+
const draft = await call('POST', `${org}/service-drafts`, { service_id: p.config.service_id, public_name: p.config.public_name });
|
|
62
|
+
cloud = { merchant_id: merchant, draft_id: draft.draft_id, revision: draft.semantic_revision, operation_bindings: {} };
|
|
63
|
+
save(cloudPath, cloud);
|
|
64
|
+
}
|
|
65
|
+
const base = `${org}/service-drafts/${encode(cloud.draft_id)}`;
|
|
66
|
+
let current = await call('GET', base + '/workflow');
|
|
67
|
+
if (current.semantic_revision !== cloud.revision)
|
|
68
|
+
throw new Error('Remote draft changed; pull and review before retrying');
|
|
69
|
+
if (cloud.local_version_id === version.version_id && cloud.hash === current.semantic_hash && cloud.version_id) {
|
|
70
|
+
const saved = await call('GET', base + '/workflow/versions');
|
|
71
|
+
if (saved.versions.some((item) => item.version_id === cloud.version_id)) {
|
|
72
|
+
const fixtureState = await call('GET', base + '/fixtures');
|
|
73
|
+
if (fixtureState.revision !== cloud.fixture_revision)
|
|
74
|
+
throw new Error('Remote test inputs changed; pull and review before retrying');
|
|
75
|
+
return { cloud, already_uploaded: true, next: 'Read the platform Guide and verify the saved version' };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const sources = join(p.state, 'sources');
|
|
79
|
+
cloud.operation_bindings ??= {};
|
|
80
|
+
if (existsSync(sources))
|
|
81
|
+
for (const file of readdirSync(sources)) {
|
|
82
|
+
const source = read(join(sources, file));
|
|
83
|
+
const imported = await call('POST', `${org}/api-intakes`, { provider_key: source.provider_key, document: source.original, media_type: 'application/yaml' });
|
|
84
|
+
for (const local of source.operations) {
|
|
85
|
+
const remote = imported.operations.find((operation) => operation.operation_key === local.operation_key && operation.method === local.method && operation.path === local.path);
|
|
86
|
+
if (!remote)
|
|
87
|
+
throw new Error('Platform import differs from the local API contract');
|
|
88
|
+
if (remote.operation_hash !== local.operation_hash)
|
|
89
|
+
throw new Error('Platform API contract changed; download and review the imported contract');
|
|
90
|
+
cloud.operation_bindings[local.provider_operation_version_id] = remote.provider_operation_version_id;
|
|
91
|
+
}
|
|
92
|
+
save(cloudPath, cloud);
|
|
93
|
+
}
|
|
94
|
+
const credentials = options.bindings ? read(resolve(options.bindings)) : {};
|
|
95
|
+
const document = validation.document;
|
|
96
|
+
for (const workflow of document.workflows)
|
|
97
|
+
for (const step of workflow.steps) {
|
|
98
|
+
const meta = step['x-itpay-operation'];
|
|
99
|
+
if (meta?.type === 'api_call') {
|
|
100
|
+
meta.providerOperationVersionId = cloud.operation_bindings[meta.providerOperationVersionId] ?? meta.providerOperationVersionId;
|
|
101
|
+
if (credentials[meta.credentialProfileId])
|
|
102
|
+
meta.credentialProfileId = credentials[meta.credentialProfileId];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Each durable boundary is recorded. On an ambiguous write, pull explicitly;
|
|
106
|
+
// never retry a guessed revision or overwrite another editor's changes.
|
|
107
|
+
current = await call('PUT', base + '/workflow', { expected_revision: cloud.revision, public_name: p.config.public_name, arazzo: document });
|
|
108
|
+
cloud.revision = current.semantic_revision;
|
|
109
|
+
save(cloudPath, cloud);
|
|
110
|
+
current = await call('PUT', base + '/pricing', { expected_revision: cloud.revision, pricing: p.config.pricing, policy: p.config.policy });
|
|
111
|
+
cloud.revision = current.semantic_revision;
|
|
112
|
+
save(cloudPath, cloud);
|
|
113
|
+
let fixtures;
|
|
114
|
+
try {
|
|
115
|
+
fixtures = await call('GET', base + '/fixtures');
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof HttpError && error.status === 404)
|
|
119
|
+
fixtures = { revision: 0 };
|
|
120
|
+
else
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
const savedFixtures = await call('PUT', base + '/fixtures', { expected_revision: fixtures.revision, fixtures: p.config.fixtures });
|
|
124
|
+
const existingVersions = await call('GET', base + '/workflow/versions');
|
|
125
|
+
const saved = existingVersions.versions.find((item) => item.name === version.name && item.arazzo_document === platformWorkflow(current)) ?? await call('POST', base + '/workflow/versions', { name: version.name, source: 'current', expected_revision: cloud.revision, arazzo_document: platformWorkflow(current) });
|
|
126
|
+
cloud.version_id = saved.version_id;
|
|
127
|
+
cloud.fixture_revision = savedFixtures.revision;
|
|
128
|
+
cloud.local_version_id = version.version_id;
|
|
129
|
+
cloud.hash = current.semantic_hash;
|
|
130
|
+
save(cloudPath, cloud);
|
|
131
|
+
await call('POST', base + '/workflow/validate', {});
|
|
132
|
+
return { cloud, platform_verified: false, next: { command: 'itpay sell verify', input: { workflow_version_id: cloud.version_id, expected_semantic_revision: cloud.revision, expected_fixture_revision: cloud.fixture_revision } }, instruction: 'Review and confirm platform verification before any real API calls. Local test success is not platform evidence.' };
|
|
133
|
+
}
|
|
134
|
+
export function registerSync(sell) {
|
|
135
|
+
for (const action of ['push', 'pull'])
|
|
136
|
+
sell.command(action).option('--project <directory>', 'Project directory', '.').option('--merchant-id <id>').option('--draft-id <id>').option('--bindings <file>', 'Local profile ID to platform credential profile ID map').option('--confirm').option('--json').action(async (options) => {
|
|
137
|
+
try {
|
|
138
|
+
process.stdout.write(JSON.stringify({ status: 'ok', result: await syncProject(action, options.project, options) }, null, 2) + '\n');
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
process.stdout.write(JSON.stringify({ status: 'error', message: error instanceof Error ? error.message : 'Sync failed' }) + '\n');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
|
+
export function sellerAuthPath(baseURL, env = process.env, purpose = "seller") {
|
|
6
|
+
return resolve(env.HOME || homedir(), '.itpay-v3', `${purpose}-${createHash('sha256').update(baseURL).digest('hex').slice(0, 16)}.json`);
|
|
7
|
+
}
|
|
8
|
+
function read(baseURL, env = process.env, purpose = "seller") {
|
|
9
|
+
const path = sellerAuthPath(baseURL, env, purpose);
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
return;
|
|
12
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
13
|
+
if (state.baseURL !== baseURL)
|
|
14
|
+
throw new Error('Seller session backend mismatch');
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
function save(state, env = process.env, purpose = "seller") {
|
|
18
|
+
const path = sellerAuthPath(state.baseURL, env, purpose);
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
20
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
21
|
+
writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
22
|
+
renameSync(tmp, path);
|
|
23
|
+
}
|
|
24
|
+
export function sellerSessionToken(baseURL, env = process.env) {
|
|
25
|
+
const state = read(baseURL, env);
|
|
26
|
+
return state?.expiresAt && Date.parse(state.expiresAt) > Date.now() ? state.sessionToken : undefined;
|
|
27
|
+
}
|
|
28
|
+
export function sellerAuth(action, baseURL, env = process.env, fetcher = fetch) {
|
|
29
|
+
return accountAuth(action, baseURL, env, fetcher);
|
|
30
|
+
}
|
|
31
|
+
export function agentAuth(action, baseURL, backend, env = process.env, fetcher = fetch) {
|
|
32
|
+
return accountAuth(action, baseURL, env, fetcher, backend);
|
|
33
|
+
}
|
|
34
|
+
async function accountAuth(action, baseURL, env, fetcher, backend) {
|
|
35
|
+
const purpose = backend ? 'agent-login' : 'seller';
|
|
36
|
+
const command = backend ? 'itpay auth status' : 'itpay sell auth status';
|
|
37
|
+
if (backend) {
|
|
38
|
+
const current = await backend.agentAccountStatus();
|
|
39
|
+
if (current.status === 'authenticated')
|
|
40
|
+
return current;
|
|
41
|
+
}
|
|
42
|
+
async function request(path, init = {}) {
|
|
43
|
+
const response = await fetcher(baseURL + path, { ...init, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw new Error(`ItPay authorization failed (${response.status}); retry login if expired`);
|
|
46
|
+
return response;
|
|
47
|
+
}
|
|
48
|
+
if (action === 'login') {
|
|
49
|
+
const response = await request('/v1/dashboard/auth-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'alipay', return_to: backend ? '/' : '/seller' }) });
|
|
50
|
+
const result = await response.json();
|
|
51
|
+
const url = new URL(result.start_url, baseURL);
|
|
52
|
+
if (url.origin !== new URL(baseURL).origin)
|
|
53
|
+
throw new Error('Unexpected authorization origin');
|
|
54
|
+
const startToken = url.searchParams.get('start_token');
|
|
55
|
+
if (!startToken || !result.poll_token || !result.dashboard_auth_session_id)
|
|
56
|
+
throw new Error('Incomplete authorization response');
|
|
57
|
+
save({ baseURL, sessionID: result.dashboard_auth_session_id, pollToken: result.poll_token, startToken }, env, purpose);
|
|
58
|
+
return { status: 'authorization_required', authorization_url: url.href, instruction: `Complete ItPay login in the browser, then run ${command}.` };
|
|
59
|
+
}
|
|
60
|
+
const state = read(baseURL, env, purpose);
|
|
61
|
+
if (action === 'logout') {
|
|
62
|
+
const token = sellerSessionToken(baseURL, env);
|
|
63
|
+
if (token)
|
|
64
|
+
await request('/v1/me/logout', { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
|
65
|
+
rmSync(sellerAuthPath(baseURL, env), { force: true });
|
|
66
|
+
return { status: 'logged_out' };
|
|
67
|
+
}
|
|
68
|
+
if (!backend && sellerSessionToken(baseURL, env))
|
|
69
|
+
return { status: 'authenticated', base_url: baseURL, expires_at: state?.expiresAt };
|
|
70
|
+
if (!state?.sessionID || !state.pollToken || !state.startToken)
|
|
71
|
+
return { status: 'login_required' };
|
|
72
|
+
const path = `/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}`;
|
|
73
|
+
const progress = await (await request(`${path}?poll_token=${encodeURIComponent(state.pollToken)}`)).json();
|
|
74
|
+
if (progress.status !== 'completed')
|
|
75
|
+
return { status: progress.status, instruction: 'Finish login and email verification in the browser.' };
|
|
76
|
+
if (backend) {
|
|
77
|
+
const result = await backend.bindAgentAccount({ dashboard_auth_session_id: state.sessionID, start_token: state.startToken });
|
|
78
|
+
if (result.status !== 'authenticated')
|
|
79
|
+
throw new Error('Agent binding did not complete');
|
|
80
|
+
rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
const claimed = await request(`${path}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
|
|
84
|
+
const token = /(?:^|[, ]+)itpay_buyer_session=([^;]+)/.exec(claimed.headers.get('set-cookie') ?? '')?.[1];
|
|
85
|
+
const session = await claimed.json();
|
|
86
|
+
if (!token || !session.expires_at || !(Date.parse(session.expires_at) > Date.now()))
|
|
87
|
+
throw new Error('Incomplete Seller session');
|
|
88
|
+
save({ baseURL, sessionToken: token, expiresAt: session.expires_at }, env);
|
|
89
|
+
return { status: 'authenticated', base_url: baseURL, expires_at: session.expires_at };
|
|
90
|
+
}
|
package/dist/src/state/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
1
|
+
import { sellerSessionToken } from "./account_auth.js";
|
|
2
|
+
// CLI configuration loader. Production defaults to app.itpay.ai; supported public overrides
|
|
3
|
+
// are the official sandbox and dev Backends. Checkout
|
|
3
4
|
// display-token persistence belongs to the cart session file, protected with
|
|
4
5
|
// owner-only permissions. Provider secrets are explicitly out of scope here.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
5
7
|
import { homedir } from "node:os";
|
|
6
8
|
import { mkdirSync } from "node:fs";
|
|
7
9
|
import { resolve } from "node:path";
|
|
@@ -11,9 +13,10 @@ import { declaredAgentType } from "./agent_type.js";
|
|
|
11
13
|
import { DeviceAuthority } from "./device_authority.js";
|
|
12
14
|
import { OperationJournal } from "./operation_journal.js";
|
|
13
15
|
export const DEFAULT_BASE_URL = "https://app.itpay.ai";
|
|
16
|
+
export const DEV_BASE_URL = "https://dev.itpay.ai";
|
|
14
17
|
export const SANDBOX_BASE_URL = "https://sandbox.itpay.ai";
|
|
15
|
-
export const CLI_VERSION = "2.
|
|
16
|
-
export const API_CONTRACT_REVISION = "sha256:
|
|
18
|
+
export const CLI_VERSION = "2.1.1";
|
|
19
|
+
export const API_CONTRACT_REVISION = "sha256:b8593e4d73782a3ddeb5d070df1db572e422dd0aa08083372bc8971b5412b643";
|
|
17
20
|
const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
|
|
18
21
|
const CART_SESSION_FILENAME = "cart.json";
|
|
19
22
|
const OPERATION_JOURNAL_FILENAME = "operations.json";
|
|
@@ -27,7 +30,7 @@ export function cliDistribution(env = process.env) {
|
|
|
27
30
|
export class BackendOverrideError extends Error {
|
|
28
31
|
code = "backend_override_forbidden";
|
|
29
32
|
constructor() {
|
|
30
|
-
super(`ITPAY_BACKEND_URL only supports ${DEFAULT_BASE_URL} or ${
|
|
33
|
+
super(`ITPAY_BACKEND_URL only supports ${DEFAULT_BASE_URL}, ${SANDBOX_BASE_URL}, or ${DEV_BASE_URL}`);
|
|
31
34
|
this.name = "BackendOverrideError";
|
|
32
35
|
}
|
|
33
36
|
}
|
|
@@ -37,21 +40,29 @@ export function resolveBackendURL(env = process.env) {
|
|
|
37
40
|
return DEFAULT_BASE_URL;
|
|
38
41
|
if (requested === SANDBOX_BASE_URL || requested === `${SANDBOX_BASE_URL}/`)
|
|
39
42
|
return SANDBOX_BASE_URL;
|
|
43
|
+
if (requested === DEV_BASE_URL || requested === `${DEV_BASE_URL}/`)
|
|
44
|
+
return DEV_BASE_URL;
|
|
45
|
+
if (env.ITPAY_CLI_DEV === "1" && /^http:\/\/(localhost|127\.0\.0\.1):\d+\/?$/.test(requested))
|
|
46
|
+
return requested.replace(/\/$/, "");
|
|
40
47
|
throw new BackendOverrideError();
|
|
41
48
|
}
|
|
42
49
|
export function qualifyBackendCommand(command, env = process.env) {
|
|
43
50
|
const requested = env.ITPAY_BACKEND_URL?.trim();
|
|
44
|
-
if (requested
|
|
51
|
+
if (!requested)
|
|
45
52
|
return command;
|
|
46
|
-
|
|
53
|
+
const target = resolveBackendURL(env);
|
|
54
|
+
if (target === DEFAULT_BASE_URL)
|
|
47
55
|
return command;
|
|
48
|
-
|
|
56
|
+
if (!command.startsWith("itpay ") || command.startsWith(`ITPAY_BACKEND_URL=${target} `))
|
|
57
|
+
return command;
|
|
58
|
+
return `ITPAY_BACKEND_URL=${target} ${command}`;
|
|
49
59
|
}
|
|
50
60
|
function stateFilename(filename, baseURL) {
|
|
51
|
-
if (baseURL
|
|
61
|
+
if (baseURL === DEFAULT_BASE_URL)
|
|
52
62
|
return filename;
|
|
63
|
+
const suffix = baseURL === SANDBOX_BASE_URL ? "sandbox" : baseURL === DEV_BASE_URL ? "dev" : `local-${createHash("sha256").update(baseURL).digest("hex").slice(0, 16)}`;
|
|
53
64
|
const dot = filename.lastIndexOf(".");
|
|
54
|
-
return dot < 0 ? `${filename}
|
|
65
|
+
return dot < 0 ? `${filename}.${suffix}` : `${filename.slice(0, dot)}.${suffix}${filename.slice(dot)}`;
|
|
55
66
|
}
|
|
56
67
|
function stateDir(env) {
|
|
57
68
|
return resolve(env.HOME || homedir(), CART_SESSION_DEFAULT_DIR);
|
|
@@ -74,7 +85,7 @@ export function loadConfig(env = process.env) {
|
|
|
74
85
|
const ideImageDirOverride = env.ITPAY_IDE_IMAGE_DIR_OVERRIDE;
|
|
75
86
|
return {
|
|
76
87
|
baseURL,
|
|
77
|
-
environment: baseURL ===
|
|
88
|
+
environment: baseURL === DEFAULT_BASE_URL ? "production" : "development",
|
|
78
89
|
...(agentType ? { agentType } : {}),
|
|
79
90
|
checkoutCurrency,
|
|
80
91
|
idempotencyKey,
|
|
@@ -104,7 +115,15 @@ export function newBackendClient(config) {
|
|
|
104
115
|
"X-ItPay-CLI-Version": CLI_VERSION,
|
|
105
116
|
"X-ItPay-Contract-Revision": API_CONTRACT_REVISION,
|
|
106
117
|
},
|
|
107
|
-
requestAuthorizer: (input) =>
|
|
118
|
+
requestAuthorizer: (input) => {
|
|
119
|
+
if (/^\/v1\/(seller\/organizations(?:\/|\?|$)|library\/)/.test(input.path)) {
|
|
120
|
+
const token = config.bearerToken ?? sellerSessionToken(config.baseURL);
|
|
121
|
+
if (!token)
|
|
122
|
+
throw new Error("Seller login required; run itpay sell auth login");
|
|
123
|
+
return Promise.resolve({ Authorization: `Bearer ${token}` });
|
|
124
|
+
}
|
|
125
|
+
return authority.authorizationHeaders(input);
|
|
126
|
+
},
|
|
108
127
|
recoverAuthorization: () => authority.recoverAuthorization(),
|
|
109
128
|
});
|
|
110
129
|
return new BackendClient(http);
|
|
@@ -2,7 +2,7 @@ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, ran
|
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, 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", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault"];
|
|
5
|
+
const PROTECTED_PATHS = ["/v1/agent-device-account-bindings", "/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault"];
|
|
6
6
|
export class DeviceAuthority {
|
|
7
7
|
baseURL;
|
|
8
8
|
backendKey;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Account login
|
|
2
|
+
|
|
3
|
+
Use `itpay auth login --json` to enroll the current Agent and open the official ItPay Web login. After the user finishes login and email verification, run `itpay auth status --json` to bind this enrolled Agent to the account. Keep declaring the actual `--agent-type` on both commands.
|
|
4
|
+
|
|
5
|
+
For dev, keep `ITPAY_BACKEND_URL=https://dev.itpay.ai` on every command. Login state and device registration are isolated from production.
|
|
6
|
+
|
|
7
|
+
Railway exact and smart query services each allow two anonymous queries after enrollment. After login, queries remain free within the published per-minute limits. On `login_required`, finish login and start a new query. On `rate_limited`, wait until the next minute; do not clear device state or retry in a loop.
|
|
8
|
+
|
|
9
|
+
The Agent receives no general account bearer token. This binding reuses the existing stable device-account ownership mechanism; switching account owners is not supported. Seller authoring continues to use `itpay sell auth` separately.
|