@envarai/envarpay 0.2.0-alpha.1 → 0.2.0-alpha.10
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 +2 -0
- package/dist/commerce/alerts.d.ts +37 -0
- package/dist/commerce/alerts.js +127 -0
- package/dist/commerce/buyer-store.d.ts +8 -0
- package/dist/commerce/buyer.d.ts +12 -3
- package/dist/commerce/buyer.js +151 -7
- package/dist/commerce/card.js +2 -2
- package/dist/commerce/cli.js +56 -6
- package/dist/commerce/client-runtime.d.ts +1 -1
- package/dist/commerce/config.d.ts +5 -1
- package/dist/commerce/config.js +37 -1
- package/dist/commerce/envar-runtime.d.ts +2 -1
- package/dist/commerce/envar-runtime.js +21 -1
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce/index.js +1 -0
- package/dist/commerce/management.js +3 -1
- package/dist/commerce/mpp-client.d.ts +11 -0
- package/dist/commerce/mpp-client.js +5 -0
- package/dist/commerce/mpp-runtime.d.ts +1 -0
- package/dist/commerce/mpp-runtime.js +1 -0
- package/dist/commerce/native-skills.d.ts +27 -0
- package/dist/commerce/native-skills.js +176 -0
- package/dist/commerce/operations.d.ts +22 -0
- package/dist/commerce/operations.js +39 -0
- package/dist/commerce/peer-proxy.js +11 -5
- package/dist/commerce/pricing.js +2 -2
- package/dist/commerce/runtime.d.ts +2 -0
- package/dist/commerce/runtime.js +2 -0
- package/dist/commerce/server.d.ts +4 -1
- package/dist/commerce/server.js +40 -9
- package/dist/commerce/setup.d.ts +18 -0
- package/dist/commerce/setup.js +344 -0
- package/dist/commerce/skill-gate.d.ts +54 -0
- package/dist/commerce/skill-gate.js +153 -0
- package/dist/commerce/store.d.ts +2 -0
- package/dist/commerce/store.js +7 -0
- package/dist/commerce/stripe-issuer.d.ts +41 -0
- package/dist/commerce/stripe-issuer.js +351 -0
- package/dist/commerce/stripe-provider.d.ts +2 -0
- package/dist/commerce/stripe-provider.js +57 -22
- package/dist/commerce/types.d.ts +27 -2
- package/dist/commerce/x402.d.ts +1 -0
- package/dist/commerce/x402.js +8 -1
- package/examples/hermes-setup-probe.py +105 -0
- package/examples/native-skill-runner.py +197 -0
- package/package.json +1 -1
- package/schemas/buyer-policy.schema.json +105 -0
- package/schemas/envarpay.config.schema.json +22 -15
package/dist/commerce/cli.js
CHANGED
|
@@ -19,12 +19,17 @@ import { CredentialVault } from './vault.js';
|
|
|
19
19
|
import { X402Gate } from './x402.js';
|
|
20
20
|
import { evmReceiptVerifier, evmSettlementRecovery } from './chain.js';
|
|
21
21
|
import { CommerceError } from './types.js';
|
|
22
|
+
import { createNativeSkillExecutor } from './native-skills.js';
|
|
23
|
+
import { inspectLedger, inspectLocalAlerts } from './operations.js';
|
|
24
|
+
import { setupHermes } from './setup.js';
|
|
22
25
|
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
23
26
|
config: { type: 'string' }, service: { type: 'string' }, offer: { type: 'string' }, origin: { type: 'string' },
|
|
24
27
|
version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
|
|
25
28
|
port: { type: 'string' }, directory: { type: 'string' }, 'envar-config': { type: 'string' }, 'envar-credentials': { type: 'string' },
|
|
29
|
+
after: { type: 'string' }, limit: { type: 'string' }, 'refund-review': { type: 'boolean' }, alerts: { type: 'boolean' }, 'fail-on-alert': { type: 'boolean' }, 'integration-state': { type: 'string' }, 'no-integration': { type: 'boolean' },
|
|
30
|
+
file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' },
|
|
26
31
|
} });
|
|
27
|
-
const usage = 'envarpay
|
|
32
|
+
const usage = 'envarpay setup --file envar-setup.json [--docker-container NAME | --python PATH] [--hermes-home PATH] [--directory DIR]\ninit --directory DIR | inspect --state DB [--after ROW --limit 100 --refund-review] [--alerts --config buyer-policy.json --integration-state DB/--no-integration --fail-on-alert] | validate --config seller.json | card --config seller.json --service ID --offer ID --origin URL | serve/buyer-serve --config FILE --credentials FILE --state DB --origin URL';
|
|
28
33
|
const closers = [];
|
|
29
34
|
function privateFile(path) {
|
|
30
35
|
const stat = lstatSync(path);
|
|
@@ -84,6 +89,27 @@ async function run() {
|
|
|
84
89
|
return;
|
|
85
90
|
}
|
|
86
91
|
const command = positionals[0];
|
|
92
|
+
if (command === 'setup') {
|
|
93
|
+
if (!values.file)
|
|
94
|
+
throw new CommerceError('setup_file', 'Download the setup file from your Agent service page and pass --file PATH');
|
|
95
|
+
await setupHermes({ file: values.file, directory: values.directory, container: values['docker-container'], python: values.python, home: values['hermes-home'] });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (values.alerts || values['fail-on-alert'] || values['integration-state'] || values['no-integration']) {
|
|
99
|
+
if (command !== 'inspect' || !values.state || !values.alerts || values['refund-review'] || values.after || values.limit || (values['no-integration'] && values['integration-state']))
|
|
100
|
+
throw new CommerceError('inspection_view', 'Alert inspection needs inspect --state and --alerts, without pagination or refund review; choose at most one integration source');
|
|
101
|
+
const report = inspectLocalAlerts(values.state, { policyPath: values.config, integrationPath: values['integration-state'], integrationDisabled: values['no-integration'] });
|
|
102
|
+
console.log(JSON.stringify(report, null, 2));
|
|
103
|
+
if (values['fail-on-alert'])
|
|
104
|
+
process.exitCode = report.recommendedExitCode;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command === 'inspect' && values.state) {
|
|
108
|
+
console.log(JSON.stringify(inspectLedger(values.state, Number(values.after ?? 0), Number(values.limit ?? 100), values['refund-review'] ? 'refund-review' : 'attention'), null, 2));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (values['refund-review'])
|
|
112
|
+
throw new CommerceError('inspection_view', 'Refund review is available only for inspect');
|
|
87
113
|
if ((values['envar-config'] || values['envar-credentials']) && command !== 'serve')
|
|
88
114
|
throw new CommerceError('envar_role', 'Configuration synchronization is available on seller serve only');
|
|
89
115
|
if (Boolean(values['envar-config']) !== Boolean(values['envar-credentials']))
|
|
@@ -117,15 +143,36 @@ async function run() {
|
|
|
117
143
|
throw new CommerceError('buyer_key', 'Signer file must contain one 32-byte 0x-prefixed private key');
|
|
118
144
|
signer = privateKeyToAccount(key);
|
|
119
145
|
}
|
|
120
|
-
if (
|
|
121
|
-
throw new CommerceError('
|
|
122
|
-
|
|
146
|
+
if (secrets.mppAdapterModule && secrets.mppStripe)
|
|
147
|
+
throw new CommerceError('mpp_configuration', 'Choose the built-in Stripe issuer or one explicit adapter module');
|
|
148
|
+
if (needsMpp && !secrets.mppAdapterModule && !secrets.mppStripe)
|
|
149
|
+
throw new CommerceError('mpp_configuration', 'MPP peers require a configured Stripe issuer or explicit authorized funding adapter');
|
|
150
|
+
const buyerVault = secrets.vaultKeyFile ? new CredentialVault(join(dirname(values.state), 'buyer-authorizations'), privateFile(secrets.vaultKeyFile)) : undefined;
|
|
151
|
+
let mpp;
|
|
152
|
+
if (needsMpp && secrets.mppStripe) {
|
|
153
|
+
const configured = secrets.mppStripe;
|
|
154
|
+
if (!configured.secretKeyFile || !configured.sellers || typeof configured.sellers !== 'object' || Array.isArray(configured.sellers))
|
|
155
|
+
throw new CommerceError('mpp_configuration', 'Stripe issuer and seller read credentials must use private key files');
|
|
156
|
+
const { createStripeMppBuyer } = await import('./stripe-issuer.js');
|
|
157
|
+
const adapter = createStripeMppBuyer({
|
|
158
|
+
payer: configured.payer, mode: configured.mode, issuerAccountId: configured.issuerAccountId,
|
|
159
|
+
secretKey: privateFile(configured.secretKeyFile).toString('utf8').trim(), paymentMethod: configured.paymentMethod,
|
|
160
|
+
...(configured.publishableKey !== undefined ? { publishableKey: configured.publishableKey } : {}),
|
|
161
|
+
sellers: Object.fromEntries(Object.entries(configured.sellers).map(([id, seller]) => [id, { accountId: seller.accountId, secretKey: privateFile(seller.secretKeyFile).toString('utf8').trim() }])),
|
|
162
|
+
...(configured.issuance ? { issuance: configured.issuance } : {}), ...(configured.returnUrl ? { returnUrl: configured.returnUrl } : {}),
|
|
163
|
+
}, { statePath: values.state + '.stripe-issuer.sqlite3', vault: buyerVault });
|
|
164
|
+
closers.push(() => adapter.close());
|
|
165
|
+
await adapter.assertReady();
|
|
166
|
+
mpp = adapter;
|
|
167
|
+
}
|
|
168
|
+
else if (needsMpp)
|
|
169
|
+
mpp = await loadMppAdapter(secrets.mppAdapterModule);
|
|
123
170
|
const { BuyerStore } = await import('./buyer-store.js');
|
|
124
171
|
const { CommerceBuyer } = await import('./buyer.js');
|
|
125
172
|
const { BuyerManagement, listenBuyerManagement } = await import('./management.js');
|
|
126
173
|
const store = new BuyerStore(values.state);
|
|
127
174
|
closers.push(() => store.close());
|
|
128
|
-
const buyer = new CommerceBuyer({ policy, store, vault:
|
|
175
|
+
const buyer = new CommerceBuyer({ policy, store, vault: buyerVault,
|
|
129
176
|
signer, mpp, peerTokens: secrets.peerTokens, peerEndpoints: secrets.peerEndpoints,
|
|
130
177
|
...(needsEvm ? { verifyReceipt: evmReceiptVerifier(secrets.rpcUrls), ...evmSettlementRecovery(secrets.rpcUrls) } : {}) });
|
|
131
178
|
closers.push(() => buyer.stop());
|
|
@@ -217,7 +264,10 @@ async function run() {
|
|
|
217
264
|
gates.mpp = gate;
|
|
218
265
|
stripeRecipientFor = ref => gate.merchantRecipient(ref);
|
|
219
266
|
}
|
|
220
|
-
const
|
|
267
|
+
const nativeSkills = secrets.skills ? createNativeSkillExecutor(secrets.skills, values.origin, config.agent.name) : undefined;
|
|
268
|
+
if (nativeSkills)
|
|
269
|
+
closers.push(() => nativeSkills.close());
|
|
270
|
+
const upstreams = new PinnedUpstreams(join(dirname(values.state), 'upstream-bindings.json'), secrets.upstreams ?? {}, secrets.upstreamInputEncoding, fetch, controller.signal, nativeSkills?.execute);
|
|
221
271
|
upstreams.prepare(config, store.catalogHistory().map(entry => entry.service));
|
|
222
272
|
const validateAdapters = (candidate) => {
|
|
223
273
|
for (const profile of Object.values(candidate.paymentProfiles)) {
|
|
@@ -4,5 +4,5 @@ export * from './buyer.js';
|
|
|
4
4
|
export * from './management.js';
|
|
5
5
|
export * from './vault.js';
|
|
6
6
|
export * from './chain.js';
|
|
7
|
-
export type { MppBuyerOptions, MppTokenOperation, MppVerificationContext, MppReceipt } from './mpp-client.js';
|
|
7
|
+
export type { MppAuthenticationAction, MppBuyerOptions, MppTokenOperation, MppVerificationContext, MppReceipt } from './mpp-client.js';
|
|
8
8
|
export * from './peer-proxy.js';
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { type BuyerPolicy, type CommerceConfig, type PaymentProfile } from './types.js';
|
|
1
|
+
import { type BuyerPolicy, type CommerceConfig, type Offer, type PaymentProfile, type Service } from './types.js';
|
|
2
2
|
export declare function atomic(value: string): bigint;
|
|
3
3
|
export declare function validateUrl(value: string, allowPrivate?: boolean): URL;
|
|
4
4
|
export declare function currencyOf(profile: PaymentProfile): string;
|
|
5
|
+
/** Stripe documents sandbox Business Profile IDs as profile_test_<opaque id>. */
|
|
6
|
+
export declare function isStripeProfileId(value: unknown): value is string;
|
|
5
7
|
export declare function digest(value: unknown): string;
|
|
8
|
+
/** Keep legacy quotes unchanged; skill-bound quotes freeze the installed skill revision too. */
|
|
9
|
+
export declare function serviceTermsDigest(configVersion: 1 | 2, service: Service, offer: Offer, profile: PaymentProfile | null): string;
|
|
6
10
|
export declare function loadCommerceConfig(value: unknown): CommerceConfig;
|
|
7
11
|
export declare function loadBuyerPolicy(value: unknown): BuyerPolicy;
|
package/dist/commerce/config.js
CHANGED
|
@@ -45,6 +45,10 @@ export function validateUrl(value, allowPrivate = false) {
|
|
|
45
45
|
export function currencyOf(profile) {
|
|
46
46
|
return profile.adapter === 'x402' ? `${profile.network}/erc20:${profile.asset.toLowerCase()}` : profile.currency;
|
|
47
47
|
}
|
|
48
|
+
/** Stripe documents sandbox Business Profile IDs as profile_test_<opaque id>. */
|
|
49
|
+
export function isStripeProfileId(value) {
|
|
50
|
+
return typeof value === 'string' && /^profile_(?:test_)?[A-Za-z0-9]{1,120}$/.test(value);
|
|
51
|
+
}
|
|
48
52
|
function assertJson(value) {
|
|
49
53
|
if (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))
|
|
50
54
|
throw new CommerceError('unsafe_json_number', 'Use strings for integers outside the safe JSON number range');
|
|
@@ -64,6 +68,13 @@ export function digest(value) {
|
|
|
64
68
|
throw new CommerceError('invalid_json', 'Cannot canonicalize input');
|
|
65
69
|
return createHash('sha256').update(serialized).digest('hex');
|
|
66
70
|
}
|
|
71
|
+
/** Keep legacy quotes unchanged; skill-bound quotes freeze the installed skill revision too. */
|
|
72
|
+
export function serviceTermsDigest(configVersion, service, offer, profile) {
|
|
73
|
+
const terms = { contract: service.contract, offer, profile };
|
|
74
|
+
return digest(configVersion === 2 && service.execution.type === 'skill'
|
|
75
|
+
? { ...terms, skill: { name: service.id, digest: service.execution.skillDigest } }
|
|
76
|
+
: terms);
|
|
77
|
+
}
|
|
67
78
|
export function loadCommerceConfig(value) {
|
|
68
79
|
if (!sellerValidator(value))
|
|
69
80
|
throw new CommerceError('invalid_config', ajv.errorsText(sellerValidator.errors));
|
|
@@ -80,6 +91,13 @@ export function loadCommerceConfig(value) {
|
|
|
80
91
|
if (services.has(service.id))
|
|
81
92
|
throw new CommerceError('duplicate_service', 'Service IDs must be unique');
|
|
82
93
|
services.add(service.id);
|
|
94
|
+
if (config.configVersion === 2 && service.execution.type === 'skill') {
|
|
95
|
+
if (service.id !== service.name)
|
|
96
|
+
throw new CommerceError('skill_name_mismatch', 'A version 2 service must use the exact installed skill name as its ID and name');
|
|
97
|
+
}
|
|
98
|
+
else if (config.configVersion === 1 && service.execution.type !== 'a2a') {
|
|
99
|
+
throw new CommerceError('legacy_skill_mode', 'Skill-bound services require configVersion 2');
|
|
100
|
+
}
|
|
83
101
|
validateUrl(service.execution.cardUrl, true);
|
|
84
102
|
noExternalRefs(service.contract.inputSchema);
|
|
85
103
|
const inputAjv = new Ajv2020({ strict: true });
|
|
@@ -143,6 +161,24 @@ export function loadBuyerPolicy(value) {
|
|
|
143
161
|
const ids = new Set();
|
|
144
162
|
for (const peer of policy.peers) {
|
|
145
163
|
validateUrl(peer.cardUrl);
|
|
164
|
+
if (peer.mode === 'standard-a2a') {
|
|
165
|
+
const endpoint = validateUrl(peer.endpoint), card = validateUrl(peer.cardUrl);
|
|
166
|
+
if (endpoint.origin !== card.origin || endpoint.search || endpoint.href !== peer.endpoint || card.href !== peer.cardUrl)
|
|
167
|
+
throw new CommerceError('standard_peer_endpoint', 'Review an exact same-origin HTTPS A2A endpoint without query parameters');
|
|
168
|
+
noExternalRefs(peer.localContract.inputSchema);
|
|
169
|
+
if (JSON.stringify(peer.localContract.inputSchema).length > 512 * 1024)
|
|
170
|
+
throw new CommerceError('standard_peer_schema', 'Local input schema exceeds 512 KiB');
|
|
171
|
+
try {
|
|
172
|
+
const inputAjv = new Ajv2020({ strict: true });
|
|
173
|
+
addFormats(inputAjv);
|
|
174
|
+
inputAjv.compile(peer.localContract.inputSchema);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new CommerceError('standard_peer_schema', 'Local input schema must be a valid supported JSON Schema');
|
|
178
|
+
}
|
|
179
|
+
if (atomic(peer.localContract.amount) > atomic(peer.maxPerPurchase))
|
|
180
|
+
throw new CommerceError('purchase_limit', 'Locally reviewed standard-peer price exceeds the per-purchase limit');
|
|
181
|
+
}
|
|
146
182
|
if (ids.has(peer.id))
|
|
147
183
|
throw new CommerceError('duplicate_peer', 'Peer IDs must be unique');
|
|
148
184
|
ids.add(peer.id);
|
|
@@ -154,7 +190,7 @@ export function loadBuyerPolicy(value) {
|
|
|
154
190
|
throw new CommerceError('invalid_recipient', 'x402 peers require a nonzero EVM recipient and USDC asset');
|
|
155
191
|
peer.recipient = peer.recipient.toLowerCase();
|
|
156
192
|
}
|
|
157
|
-
else if (peer.currency !== 'usd' ||
|
|
193
|
+
else if (peer.currency !== 'usd' || !isStripeProfileId(peer.recipient))
|
|
158
194
|
throw new CommerceError('invalid_recipient', 'MPP peers require USD and a verified Stripe merchant profile');
|
|
159
195
|
const limit = limits.get(peer.currency);
|
|
160
196
|
if (limit === undefined || atomic(peer.maxPerPurchase) > limit)
|
|
@@ -8,10 +8,11 @@ export declare class PinnedUpstreams {
|
|
|
8
8
|
private readonly inputEncoding;
|
|
9
9
|
private readonly fetchImpl;
|
|
10
10
|
private readonly signal?;
|
|
11
|
+
private readonly skills?;
|
|
11
12
|
private bindings;
|
|
12
13
|
private readonly executors;
|
|
13
14
|
readonly execute: ExecuteOrder;
|
|
14
|
-
constructor(path: string, credentials: Readonly<Record<string, string>>, inputEncoding?: Readonly<Record<string, "data" | "json-text">>, fetchImpl?: typeof fetch, signal?: AbortSignal | undefined);
|
|
15
|
+
constructor(path: string, credentials: Readonly<Record<string, string>>, inputEncoding?: Readonly<Record<string, "data" | "json-text">>, fetchImpl?: typeof fetch, signal?: AbortSignal | undefined, skills?: ExecuteOrder | undefined);
|
|
15
16
|
prepare(config: CommerceConfig, history?: readonly Service[]): void;
|
|
16
17
|
private executor;
|
|
17
18
|
}
|
|
@@ -50,15 +50,17 @@ export class PinnedUpstreams {
|
|
|
50
50
|
inputEncoding;
|
|
51
51
|
fetchImpl;
|
|
52
52
|
signal;
|
|
53
|
+
skills;
|
|
53
54
|
bindings = {};
|
|
54
55
|
executors = new Map();
|
|
55
56
|
execute;
|
|
56
|
-
constructor(path, credentials, inputEncoding = {}, fetchImpl = fetch, signal) {
|
|
57
|
+
constructor(path, credentials, inputEncoding = {}, fetchImpl = fetch, signal, skills) {
|
|
57
58
|
this.path = path;
|
|
58
59
|
this.credentials = credentials;
|
|
59
60
|
this.inputEncoding = inputEncoding;
|
|
60
61
|
this.fetchImpl = fetchImpl;
|
|
61
62
|
this.signal = signal;
|
|
63
|
+
this.skills = skills;
|
|
62
64
|
if (existsSync(path)) {
|
|
63
65
|
const value = privateJson(path);
|
|
64
66
|
if (value.version !== 1 ||
|
|
@@ -92,10 +94,22 @@ export class PinnedUpstreams {
|
|
|
92
94
|
}
|
|
93
95
|
return undefined;
|
|
94
96
|
};
|
|
97
|
+
if (skills) {
|
|
98
|
+
this.execute.assertSkill = (service) => skills.assertSkill(service);
|
|
99
|
+
this.execute.skillCard = skills.skillCard;
|
|
100
|
+
}
|
|
95
101
|
}
|
|
96
102
|
prepare(config, history = []) {
|
|
97
103
|
const next = structuredClone(this.bindings), current = new Set(config.services.map((s) => `${s.id}:${s.revision}`));
|
|
98
104
|
for (const service of [...history, ...config.services]) {
|
|
105
|
+
if (service.execution.type === 'skill') {
|
|
106
|
+
if (!this.skills)
|
|
107
|
+
throw new CommerceError('skill_gate_required', 'Configure the native skill runtime');
|
|
108
|
+
// Retired versions stay readable even after their skill package is upgraded.
|
|
109
|
+
if (current.has(`${service.id}:${service.revision}`))
|
|
110
|
+
this.skills.assertSkill(service);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
99
113
|
const key = `${service.id}:${service.revision}`, saved = next[key], explicit = this.credentials[key];
|
|
100
114
|
if (saved) {
|
|
101
115
|
if (saved.cardUrl !== service.execution.cardUrl ||
|
|
@@ -120,6 +134,11 @@ export class PinnedUpstreams {
|
|
|
120
134
|
this.bindings = next;
|
|
121
135
|
}
|
|
122
136
|
executor(service) {
|
|
137
|
+
if (service.execution.type === 'skill') {
|
|
138
|
+
if (!this.skills)
|
|
139
|
+
throw new CommerceError('skill_gate_required', 'Configure the native skill runtime');
|
|
140
|
+
return this.skills;
|
|
141
|
+
}
|
|
123
142
|
const key = `${service.id}:${service.revision}`, binding = this.bindings[key];
|
|
124
143
|
if (!binding || binding.cardUrl !== service.execution.cardUrl)
|
|
125
144
|
throw new CommerceError("upstream_binding_missing", "Original runtime credential binding is unavailable");
|
|
@@ -147,6 +166,7 @@ export function mergeEnvarService(base, candidate) {
|
|
|
147
166
|
const profiles = { ...base.paymentProfiles, ...incoming.paymentProfiles }, services = [...retained, service], used = new Set(services.flatMap((s) => s.offers.map((o) => o.paymentProfile).filter((p) => !!p)));
|
|
148
167
|
return loadCommerceConfig({
|
|
149
168
|
...base,
|
|
169
|
+
configVersion: Math.max(base.configVersion, incoming.configVersion),
|
|
150
170
|
paymentProfiles: Object.fromEntries(Object.entries(profiles).filter(([key]) => used.has(key))),
|
|
151
171
|
services,
|
|
152
172
|
});
|
package/dist/commerce/index.d.ts
CHANGED
package/dist/commerce/index.js
CHANGED
|
@@ -77,9 +77,11 @@ export class BuyerManagement {
|
|
|
77
77
|
throw new CommerceError('invalid_request', 'Confirmation identifiers must be strings');
|
|
78
78
|
return Response.json(await this.options.buyer.confirm(caller, body), { headers });
|
|
79
79
|
}
|
|
80
|
-
const match = /^\/management\/v1\/purchases\/([0-9a-f-]{36})(\/(?:recover|continue))?$/.exec(url.pathname);
|
|
80
|
+
const match = /^\/management\/v1\/purchases\/([0-9a-f-]{36})(\/(?:recover|continue|authentication))?$/.exec(url.pathname);
|
|
81
81
|
if (match && request.method === 'GET' && !match[2])
|
|
82
82
|
return Response.json(this.options.buyer.get(caller, match[1]), { headers });
|
|
83
|
+
if (match && request.method === 'GET' && match[2] === '/authentication')
|
|
84
|
+
return Response.json(await this.options.buyer.authentication(caller, match[1]), { headers });
|
|
83
85
|
if (match && request.method === 'POST' && match[2] === '/continue') {
|
|
84
86
|
const body = await jsonBody(request);
|
|
85
87
|
exactKeys(body, ['messageId', 'input']);
|
|
@@ -28,16 +28,27 @@ export interface MppVerificationContext {
|
|
|
28
28
|
mode: 'test' | 'live';
|
|
29
29
|
operationId: string;
|
|
30
30
|
}
|
|
31
|
+
export interface MppAuthenticationAction {
|
|
32
|
+
type: 'use_stripe_sdk';
|
|
33
|
+
hashedValue: string;
|
|
34
|
+
publishableKey: string;
|
|
35
|
+
}
|
|
36
|
+
/** Explicit official sandbox aliases, never accepted for live funding. */
|
|
37
|
+
export declare const STRIPE_TEST_PAYMENT_METHODS: readonly ["pm_card_visa", "pm_card_visa_chargeDeclined", "pm_card_authenticationRequired"];
|
|
38
|
+
export declare function isStripeTestPaymentMethod(value: string): boolean;
|
|
31
39
|
export interface MppBuyerOptions {
|
|
32
40
|
/** Stable private buyer/PSP identity; never inferred from seller metadata. */
|
|
33
41
|
payer: string;
|
|
34
42
|
mode: 'test' | 'live';
|
|
35
43
|
paymentMethod: string;
|
|
44
|
+
issuance?: 'issued-token' | 'test-helper';
|
|
36
45
|
createToken: (operation: MppTokenOperation) => Promise<string>;
|
|
37
46
|
/** Read the original operation only; never create another SPT on absence. */
|
|
38
47
|
recoverToken?: (operation: MppTokenOperation) => Promise<string | undefined>;
|
|
39
48
|
/** Independently read provider state and match account/mode/merchant/amount/order. */
|
|
40
49
|
verifyReceipt: (receipt: MppReceipt, context: MppVerificationContext) => Promise<boolean>;
|
|
50
|
+
/** Read only, for the owner of this exact original purchase. Never completes payment. */
|
|
51
|
+
getAuthentication?: (context: MppVerificationContext) => Promise<MppAuthenticationAction | undefined>;
|
|
41
52
|
}
|
|
42
53
|
export interface SavedMppAuthorization {
|
|
43
54
|
protocol: 'mpp';
|
|
@@ -3,6 +3,11 @@ import { Mppx } from 'mppx/client';
|
|
|
3
3
|
import { charge } from 'mppx/stripe/client';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { CommerceError } from './types.js';
|
|
6
|
+
/** Explicit official sandbox aliases, never accepted for live funding. */
|
|
7
|
+
export const STRIPE_TEST_PAYMENT_METHODS = ['pm_card_visa', 'pm_card_visa_chargeDeclined', 'pm_card_authenticationRequired'];
|
|
8
|
+
export function isStripeTestPaymentMethod(value) {
|
|
9
|
+
return STRIPE_TEST_PAYMENT_METHODS.includes(value);
|
|
10
|
+
}
|
|
6
11
|
/** Native SDK preparation only; no fetch polyfill or automatic payment retry. */
|
|
7
12
|
export async function prepareMppCredential(challenge, paymentMethod, createToken) {
|
|
8
13
|
const client = Mppx.create({ polyfill: false, maxPaymentRetries: 0, methods: [charge({ paymentMethod, createToken })] });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type SkillDescriptor } from './skill-gate.js';
|
|
2
|
+
import type { ExecuteOrder } from './server.js';
|
|
3
|
+
export interface NativeSkillSettings {
|
|
4
|
+
framework: 'hermes' | 'openclaw';
|
|
5
|
+
skillsDirectory: string;
|
|
6
|
+
freeSkills: string[];
|
|
7
|
+
stateDirectory: string;
|
|
8
|
+
python: string;
|
|
9
|
+
command?: string[];
|
|
10
|
+
model: string;
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
apiKeyFile: string;
|
|
13
|
+
/** Host-side Docker CLI, never a Docker socket mounted into the Agent. */
|
|
14
|
+
dockerContainer?: string;
|
|
15
|
+
dockerStateDirectory?: string;
|
|
16
|
+
}
|
|
17
|
+
interface InstalledSkill {
|
|
18
|
+
descriptor: SkillDescriptor;
|
|
19
|
+
instructions: string;
|
|
20
|
+
}
|
|
21
|
+
/** Hash every resource; reject symlinks, unbounded packages and executable skill requirements. */
|
|
22
|
+
export declare function readInstalledSkills(root: string, freeSkills: readonly string[], cardUrl: string): InstalledSkill[];
|
|
23
|
+
export declare function createNativeSkillExecutor(settings: NativeSkillSettings, origin: string, agentName: string): {
|
|
24
|
+
execute: ExecuteOrder;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Owner-operated, instruction-only skill runtime. No model file, shell or network tools. */
|
|
2
|
+
import { AgentCard, Task } from '@a2a-js/sdk';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { lstatSync, readFileSync, readdirSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { resolve, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { digest } from './config.js';
|
|
10
|
+
import { CommerceError } from './types.js';
|
|
11
|
+
import { skillScopedExecutor } from './skill-gate.js';
|
|
12
|
+
const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
|
|
13
|
+
/** Hash every resource; reject symlinks, unbounded packages and executable skill requirements. */
|
|
14
|
+
export function readInstalledSkills(root, freeSkills, cardUrl) {
|
|
15
|
+
const base = resolve(root), rootStat = lstatSync(base);
|
|
16
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
17
|
+
throw new CommerceError('skill_directory', 'Use a real installed skill directory');
|
|
18
|
+
const output = [];
|
|
19
|
+
for (const entry of readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
20
|
+
if (entry.name.startsWith('.'))
|
|
21
|
+
continue;
|
|
22
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !namePattern.test(entry.name))
|
|
23
|
+
throw new CommerceError('skill_package', 'Use one real directory per exact skill name');
|
|
24
|
+
const files = [];
|
|
25
|
+
let total = 0;
|
|
26
|
+
function walk(folder, prefix = '') {
|
|
27
|
+
if (prefix.split('/').length > 12)
|
|
28
|
+
throw new CommerceError('skill_size', 'Skill package nesting exceeds the supported limit');
|
|
29
|
+
for (const item of readdirSync(folder, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
30
|
+
const path = join(folder, item.name), relative = prefix + item.name;
|
|
31
|
+
if (item.isSymbolicLink())
|
|
32
|
+
throw new CommerceError('skill_symlink', 'Skill packages cannot contain symlinks');
|
|
33
|
+
if (item.isDirectory()) {
|
|
34
|
+
walk(path, relative + '/');
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const stat = lstatSync(path);
|
|
38
|
+
if (!stat.isFile() || stat.size > 512 * 1024 || files.length >= 128 || (total += stat.size) > 1024 * 1024)
|
|
39
|
+
throw new CommerceError('skill_size', 'Skill package exceeds supported size');
|
|
40
|
+
files.push({ path: relative, sha256: createHash('sha256').update(readFileSync(path)).digest('hex') });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
walk(join(base, entry.name));
|
|
44
|
+
const instructions = readFileSync(join(base, entry.name, 'SKILL.md'), 'utf8');
|
|
45
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(instructions)?.[1];
|
|
46
|
+
const name = /^name:\s*["']?([a-z][a-z0-9-]*)["']?\s*$/m.exec(front ?? '')?.[1];
|
|
47
|
+
if (name !== entry.name || instructions.length > 48000)
|
|
48
|
+
throw new CommerceError('skill_name_mismatch', 'SKILL.md name must match its installed directory');
|
|
49
|
+
// This adapter deliberately supports only text-in/text-out workflows. Owners must use
|
|
50
|
+
// a separately reviewed adapter for skills needing code, browsing, external APIs or files.
|
|
51
|
+
if (!/^envar-runtime:\s*instruction-only\s*$/m.test(front ?? ''))
|
|
52
|
+
throw new CommerceError('skill_runtime_required', 'Review and mark each compatible skill envar-runtime: instruction-only before publication');
|
|
53
|
+
const description = /^description:\s*(.+)$/m.exec(front ?? '')?.[1]?.replace(/^["']|["']$/g, '') ?? name;
|
|
54
|
+
const references = files.filter(file => file.path !== 'SKILL.md' && file.path.endsWith('.md')).map(file => `\n\n<skill-reference path="${file.path}">\n${readFileSync(join(base, entry.name, file.path), 'utf8')}\n</skill-reference>`).join('');
|
|
55
|
+
if (instructions.length + references.length > 120000)
|
|
56
|
+
throw new CommerceError('skill_size', 'Instruction resources exceed the supported context size');
|
|
57
|
+
output.push({ descriptor: { name, digest: digest(files), cardUrl, access: freeSkills.includes(name) ? 'free' : 'paid', description }, instructions: instructions + references });
|
|
58
|
+
}
|
|
59
|
+
if (!output.length || output.length > 32 || freeSkills.some(name => !output.some(s => s.descriptor.name === name)))
|
|
60
|
+
throw new CommerceError('skill_inventory', 'Install between 1 and 32 skills and declare only installed free skills');
|
|
61
|
+
// Free helpers are executable dependencies of every purchase, so their versions
|
|
62
|
+
// and access-policy changes must invalidate the quote's selected skill digest too.
|
|
63
|
+
const helpers = output.filter(s => s.descriptor.access === 'free').map(s => ({ name: s.descriptor.name, digest: s.descriptor.digest }));
|
|
64
|
+
for (const skill of output)
|
|
65
|
+
skill.descriptor.digest = digest({ package: skill.descriptor.digest, helpers });
|
|
66
|
+
return output;
|
|
67
|
+
}
|
|
68
|
+
export function createNativeSkillExecutor(settings, origin, agentName) {
|
|
69
|
+
if (!['hermes', 'openclaw'].includes(settings.framework) || !Array.isArray(settings.freeSkills) || !settings.python || !settings.model || !settings.baseUrl)
|
|
70
|
+
throw new CommerceError('skill_settings', 'Configure a supported native skill runtime');
|
|
71
|
+
const cardUrl = new URL('/.well-known/agent-card.json', origin).href;
|
|
72
|
+
const installed = () => readInstalledSkills(settings.skillsDirectory, settings.freeSkills, cardUrl);
|
|
73
|
+
const initial = installed();
|
|
74
|
+
mkdirSync(settings.stateDirectory, { recursive: true, mode: 0o700 });
|
|
75
|
+
const stateStat = lstatSync(settings.stateDirectory);
|
|
76
|
+
if (!stateStat.isDirectory() || stateStat.isSymbolicLink() || (stateStat.mode & 0o077) !== 0)
|
|
77
|
+
throw new CommerceError('skill_state', 'Skill state must be owner-only');
|
|
78
|
+
const db = new DatabaseSync(join(settings.stateDirectory, 'skills.sqlite3'));
|
|
79
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS skill_tasks (id TEXT PRIMARY KEY, order_id TEXT UNIQUE NOT NULL, binding TEXT NOT NULL, task_json TEXT NOT NULL)');
|
|
80
|
+
for (const row of db.prepare('SELECT id,task_json FROM skill_tasks').all()) {
|
|
81
|
+
const task = JSON.parse(String(row.task_json));
|
|
82
|
+
if (task.status?.state === 'TASK_STATE_WORKING') {
|
|
83
|
+
task.status = { state: 'TASK_STATE_FAILED', message: { role: 'ROLE_AGENT', messageId: randomUUID(), parts: [{ text: 'Native execution interrupted; inspect this original order' }] } };
|
|
84
|
+
db.prepare('UPDATE skill_tasks SET task_json=? WHERE id=?').run(JSON.stringify(task), row.id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const inFlight = new Map();
|
|
88
|
+
const runner = fileURLToPath(new URL('../../examples/native-skill-runner.py', import.meta.url));
|
|
89
|
+
function readTask(id, invocation) {
|
|
90
|
+
const row = db.prepare('SELECT order_id,task_json FROM skill_tasks WHERE id=?').get(id);
|
|
91
|
+
if (!row || row.order_id !== invocation.orderId)
|
|
92
|
+
throw new CommerceError('skill_task_not_found', 'Original skill Task not found');
|
|
93
|
+
return Task.fromJSON(JSON.parse(String(row.task_json)));
|
|
94
|
+
}
|
|
95
|
+
function executeProcess(payload) {
|
|
96
|
+
return new Promise((accept, reject) => {
|
|
97
|
+
const docker = settings.dockerContainer;
|
|
98
|
+
if (docker && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(docker))
|
|
99
|
+
throw new CommerceError('skill_container', 'Invalid Docker container name');
|
|
100
|
+
const child = docker
|
|
101
|
+
? spawn('docker', ['exec', '-i', docker, settings.python, '-c', readFileSync(runner, 'utf8')], { stdio: ['pipe', 'pipe', 'pipe'], env: { PATH: process.env.PATH, HOME: process.env.HOME, DOCKER_CONFIG: process.env.DOCKER_CONFIG, DOCKER_HOST: process.env.DOCKER_HOST } })
|
|
102
|
+
: spawn(settings.python, [runner], { stdio: ['pipe', 'pipe', 'pipe'], env: { PATH: process.env.PATH, LANG: 'C.UTF-8', PYTHONUNBUFFERED: '1' } });
|
|
103
|
+
const chunks = [];
|
|
104
|
+
let size = 0;
|
|
105
|
+
const timeout = setTimeout(() => child.kill('SIGTERM'), 240000);
|
|
106
|
+
child.stdout.on('data', (chunk) => { size += chunk.length; if (size > 1024 * 1024)
|
|
107
|
+
child.kill('SIGTERM');
|
|
108
|
+
else
|
|
109
|
+
chunks.push(chunk); });
|
|
110
|
+
child.stderr.resume();
|
|
111
|
+
child.on('error', () => { clearTimeout(timeout); reject(new CommerceError('skill_runtime_start', 'Native skill runtime could not start')); });
|
|
112
|
+
child.on('close', code => {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
try {
|
|
115
|
+
if (code !== 0 || size > 1024 * 1024)
|
|
116
|
+
throw Error();
|
|
117
|
+
const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
118
|
+
if (typeof result.output !== 'string' || !result.output.trim() || result.framework !== settings.framework || !Array.isArray(result.tools) || result.tools.length)
|
|
119
|
+
throw Error();
|
|
120
|
+
accept(result);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
reject(new CommerceError('skill_runtime_failed', 'Native skill execution failed; inspect the original Task without resubmitting'));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
child.stdin.on('error', () => { });
|
|
127
|
+
child.stdin.end(JSON.stringify(payload));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const runtime = {
|
|
131
|
+
list: () => installed().map(s => s.descriptor),
|
|
132
|
+
async send(name, input, invocation) {
|
|
133
|
+
const skills = installed(), selected = skills.find(s => s.descriptor.name === name);
|
|
134
|
+
if (!selected || selected.descriptor.digest !== invocation.grant.skillDigest)
|
|
135
|
+
throw new CommerceError('skill_changed', 'The purchased skill version is no longer installed');
|
|
136
|
+
invocation.grant.require(name);
|
|
137
|
+
const allowed = skills.filter(s => s.descriptor.name === name || s.descriptor.access === 'free');
|
|
138
|
+
allowed.forEach(s => invocation.grant.require(s.descriptor.name));
|
|
139
|
+
const binding = digest({ name, version: selected.descriptor.digest, caller: invocation.grant.caller, input, allowed: allowed.map(s => s.descriptor) });
|
|
140
|
+
const prior = db.prepare('SELECT id,binding FROM skill_tasks WHERE order_id=?').get(invocation.orderId);
|
|
141
|
+
if (prior) {
|
|
142
|
+
if (prior.binding !== binding)
|
|
143
|
+
throw new CommerceError('skill_task_conflict', 'An order already identifies a different skill input');
|
|
144
|
+
return readTask(String(prior.id), invocation);
|
|
145
|
+
}
|
|
146
|
+
const id = randomUUID();
|
|
147
|
+
// A crash cannot cause a second native dispatch. The durable Task remains failed until owner inspection.
|
|
148
|
+
const pending = Task.fromJSON({ id, status: { state: 'TASK_STATE_WORKING' } });
|
|
149
|
+
db.prepare('INSERT INTO skill_tasks VALUES (?,?,?,?)').run(id, invocation.orderId, binding, JSON.stringify(Task.toJSON(pending)));
|
|
150
|
+
const operation = (async () => {
|
|
151
|
+
let task;
|
|
152
|
+
try {
|
|
153
|
+
const result = await executeProcess({ settings: { ...settings, stateDirectory: join(settings.dockerStateDirectory ?? settings.stateDirectory, id) }, skillName: name, skills: allowed.map(s => ({ name: s.descriptor.name, instructions: s.instructions })), input });
|
|
154
|
+
task = Task.fromJSON({ id, status: { state: 'TASK_STATE_COMPLETED' }, artifacts: [{ artifactId: 'result', name: name + '.md', parts: [{ text: result.output, mediaType: 'text/markdown' }] }], metadata: { skillName: name, skillDigest: selected.descriptor.digest, allowedSkills: allowed.map(s => s.descriptor.name), framework: settings.framework, tools: result.tools, sessionId: result.sessionId ?? id } });
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
task = Task.fromJSON({ id, status: { state: 'TASK_STATE_FAILED' }, metadata: { errorCode: error instanceof CommerceError ? error.code : 'skill_runtime_failed' } });
|
|
158
|
+
}
|
|
159
|
+
db.prepare('UPDATE skill_tasks SET task_json=? WHERE id=?').run(JSON.stringify(Task.toJSON(task)), id);
|
|
160
|
+
return task;
|
|
161
|
+
})();
|
|
162
|
+
inFlight.set(id, operation);
|
|
163
|
+
void operation.finally(() => inFlight.delete(id));
|
|
164
|
+
return pending;
|
|
165
|
+
},
|
|
166
|
+
async getTask(_name, id, invocation) { return readTask(id, invocation); },
|
|
167
|
+
};
|
|
168
|
+
const execute = skillScopedExecutor(runtime);
|
|
169
|
+
execute.skillCard = () => {
|
|
170
|
+
const skills = installed();
|
|
171
|
+
return AgentCard.fromJSON({ name: agentName, description: 'Installed Agent Skills', version: '1.0', supportedInterfaces: [{ url: new URL('/a2a', origin).href, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }], capabilities: { extensions: [{ uri: 'urn:envarpay:skill-gate:1', required: false, params: { runtime: 'instruction-only-v1', skills: skills.map(s => ({ name: s.descriptor.name, digest: s.descriptor.digest, access: s.descriptor.access })) } }] }, defaultInputModes: ['application/json'], defaultOutputModes: ['text/markdown'], skills: skills.map(s => ({ id: s.descriptor.name, name: s.descriptor.name, description: s.descriptor.description, tags: [s.descriptor.name] })) });
|
|
172
|
+
};
|
|
173
|
+
if (initial.length === 0)
|
|
174
|
+
throw new CommerceError('skill_inventory', 'No installed skills');
|
|
175
|
+
return { execute, close: async () => { await Promise.allSettled(inFlight.values()); db.close(); } };
|
|
176
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { inspectLocalAlerts, type LocalAlertOptions } from './alerts.js';
|
|
2
|
+
export type InspectionView = 'attention' | 'refund-review';
|
|
3
|
+
/** Local owner-only diagnostics: never opens a signer, vault, provider or network. */
|
|
4
|
+
export declare function inspectLedger(path: string, after?: number, limit?: number, view?: InspectionView): {
|
|
5
|
+
role: string;
|
|
6
|
+
view: InspectionView;
|
|
7
|
+
total: number;
|
|
8
|
+
attentionCount: number;
|
|
9
|
+
refundReviewCount: number;
|
|
10
|
+
items: {
|
|
11
|
+
reason?: "confirmed_payment_failed_execution" | undefined;
|
|
12
|
+
cursor: number;
|
|
13
|
+
id: string;
|
|
14
|
+
paymentState: string;
|
|
15
|
+
executionState: string;
|
|
16
|
+
taskId: string | null;
|
|
17
|
+
}[];
|
|
18
|
+
nextCursor: number;
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
paymentPerformed: boolean;
|
|
21
|
+
refundPerformed: boolean;
|
|
22
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { lstatSync } from 'node:fs';
|
|
3
|
+
import { CommerceError } from './types.js';
|
|
4
|
+
export { inspectLocalAlerts } from './alerts.js';
|
|
5
|
+
/** Local owner-only diagnostics: never opens a signer, vault, provider or network. */
|
|
6
|
+
export function inspectLedger(path, after = 0, limit = 100, view = 'attention') {
|
|
7
|
+
const stat = lstatSync(path);
|
|
8
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()))
|
|
9
|
+
throw new CommerceError('private_file', 'Inspect an existing ledger owned only by the current user');
|
|
10
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
11
|
+
throw new CommerceError('inspection_cursor', 'Use a nonnegative row cursor and a limit from1 to100');
|
|
12
|
+
if (!['attention', 'refund-review'].includes(view))
|
|
13
|
+
throw new CommerceError('inspection_view', 'Use attention or refund-review inspection');
|
|
14
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
15
|
+
try {
|
|
16
|
+
db.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=1000');
|
|
17
|
+
const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(row => row.name));
|
|
18
|
+
const role = names.has('buyer_purchases') ? 'buyer' : names.has('commerce_orders') ? 'seller' : null;
|
|
19
|
+
if (!role)
|
|
20
|
+
throw new CommerceError('store_version', 'Unrecognized commerce ledger');
|
|
21
|
+
const meta = role === 'buyer' ? 'buyer_meta' : 'commerce_meta', version = role === 'buyer' ? 1 : 2;
|
|
22
|
+
if (db.prepare(`SELECT version FROM ${meta}`).get()?.version !== version)
|
|
23
|
+
throw new CommerceError('store_version', 'Unsupported commerce ledger version');
|
|
24
|
+
const table = role === 'buyer' ? 'buyer_purchases' : 'commerce_orders';
|
|
25
|
+
const total = Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
|
|
26
|
+
const attention = role === 'buyer' ? "json_extract(record_json,'$.state') IN ('signing','submitted','unknown') OR json_extract(record_json,'$.paymentState') IN ('reserved','unknown') OR json_extract(record_json,'$.executionState')='unknown'" : "payment_state IN ('settling','unknown') OR execution_state IN ('dispatching','unknown')";
|
|
27
|
+
const refundReview = role === 'buyer' ? "json_extract(record_json,'$.paymentState')='confirmed' AND json_extract(record_json,'$.executionState')='failed'" : "payment_state='confirmed' AND execution_state='failed'";
|
|
28
|
+
const filter = view === 'refund-review' ? refundReview : attention;
|
|
29
|
+
const selected = role === 'buyer' ? "rowid AS cursor,id,json_extract(record_json,'$.paymentState') AS payment_state,json_extract(record_json,'$.executionState') AS execution_state,json_extract(record_json,'$.task.id') AS task_id" : "rowid AS cursor,id,payment_state,execution_state,task_id";
|
|
30
|
+
const rows = db.prepare(`SELECT ${selected} FROM ${table} WHERE rowid>? AND (${filter}) ORDER BY rowid LIMIT ?`).all(after, limit + 1);
|
|
31
|
+
const page = rows.slice(0, limit).map(row => ({ cursor: Number(row.cursor), id: String(row.id), paymentState: String(row.payment_state), executionState: String(row.execution_state), taskId: typeof row.task_id === 'string' ? row.task_id : null, ...(view === 'refund-review' ? { reason: 'confirmed_payment_failed_execution' } : {}) }));
|
|
32
|
+
const attentionCount = Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${attention}`).get().n);
|
|
33
|
+
const refundReviewCount = Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${refundReview}`).get().n);
|
|
34
|
+
return { role, view, total, attentionCount, refundReviewCount, items: page, nextCursor: page.at(-1)?.cursor ?? after, hasMore: rows.length > limit, paymentPerformed: false, refundPerformed: false };
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
db.close();
|
|
38
|
+
}
|
|
39
|
+
}
|