@onchaindiligence/sdk 0.2.0 → 0.3.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/README.md +95 -8
- package/conformance/attestation-v1-v2-vectors.json +98 -0
- package/conformance/rfc8785-vectors.json +31 -0
- package/dist/commerce/client.d.ts +134 -0
- package/dist/commerce/client.js +511 -0
- package/dist/commerce/evidenceExport.d.ts +51 -0
- package/dist/commerce/evidenceExport.js +44 -0
- package/dist/commerce/executor.d.ts +64 -0
- package/dist/commerce/executor.js +19 -0
- package/dist/commerce/index.d.ts +29 -0
- package/dist/commerce/index.js +29 -0
- package/dist/commerce/mockExecutor.d.ts +41 -0
- package/dist/commerce/mockExecutor.js +74 -0
- package/dist/commerce/nodeFileRecoveryStore.d.ts +17 -0
- package/dist/commerce/nodeFileRecoveryStore.js +126 -0
- package/dist/commerce/policyTemplates.d.ts +48 -0
- package/dist/commerce/policyTemplates.js +54 -0
- package/dist/commerce/recoveryStore.d.ts +73 -0
- package/dist/commerce/recoveryStore.js +77 -0
- package/dist/commerce/results.d.ts +90 -0
- package/dist/commerce/results.js +3 -0
- package/dist/commerce/types.d.ts +147 -0
- package/dist/commerce/types.js +13 -0
- package/dist/commerce/x402Executor.d.ts +43 -0
- package/dist/commerce/x402Executor.js +222 -0
- package/dist/index.d.ts +36 -32
- package/dist/index.js +155 -86
- package/dist/verification.d.ts +109 -0
- package/dist/verification.js +621 -0
- package/package.json +19 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* executor.ts — the CommerceExecutor contract (D2.5, Section 3).
|
|
3
|
+
*
|
|
4
|
+
* OCD evaluates. The executor authorizes and submits. These are DELIBERATELY
|
|
5
|
+
* independent: an OCD ALLOW is a policy opinion, never a grant of wallet
|
|
6
|
+
* authority, and this interface exists precisely so a developer can swap
|
|
7
|
+
* wallets/providers without OCD code ever touching a private key or a
|
|
8
|
+
* payment authorization it didn't need to see.
|
|
9
|
+
*
|
|
10
|
+
* `prepare()` MUST NOT broadcast anything — it is the point where a durable
|
|
11
|
+
* execution/payment identity is created (and, in the commerce client's
|
|
12
|
+
* orchestration, persisted to the recovery store and registered with OCD's
|
|
13
|
+
* execution-bindings endpoint) BEFORE any state-changing network call.
|
|
14
|
+
* `submit()` is called AT MOST ONCE per prepared identity by the orchestrator
|
|
15
|
+
* — an executor must never invent a second identity/authorization on its
|
|
16
|
+
* own initiative. `resume()` must query/resume the SAME prepared identity,
|
|
17
|
+
* never fabricate a new payment.
|
|
18
|
+
*/
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @onchaindiligence/sdk/commerce
|
|
3
|
+
* ------------------------------
|
|
4
|
+
* The online commerce client (D2.5): open/resume a durable OCD operation,
|
|
5
|
+
* evaluate preflight, execute through an independent executor, and
|
|
6
|
+
* observe/finalize into a signed Commerce Receipt — without hand-assembling
|
|
7
|
+
* onchaindiligence-mcp's D2.4 lifecycle machinery yourself.
|
|
8
|
+
*
|
|
9
|
+
* import { createCommerceClient, MockCommerceExecutor } from '@onchaindiligence/sdk/commerce'
|
|
10
|
+
*
|
|
11
|
+
* Kept separate from the package's root export (the OFFLINE compliance-API
|
|
12
|
+
* client + zero-network attestation verifier) — this module makes network
|
|
13
|
+
* calls and requires a durable recovery store; the root export does not.
|
|
14
|
+
*
|
|
15
|
+
* BROWSER-SAFE by construction: everything here bundles for
|
|
16
|
+
* `--platform=browser` (no Node built-ins). `NodeFileRecoveryStore` is
|
|
17
|
+
* genuinely Node-only (it wraps `node:fs`) and lives at the separate
|
|
18
|
+
* `@onchaindiligence/sdk/commerce/node` subpath instead — importing THIS
|
|
19
|
+
* barrel from a browser bundle can never accidentally pull in `node:fs`.
|
|
20
|
+
*/
|
|
21
|
+
export * from './types.js';
|
|
22
|
+
export * from './results.js';
|
|
23
|
+
export * from './executor.js';
|
|
24
|
+
export * from './recoveryStore.js';
|
|
25
|
+
export * from './mockExecutor.js';
|
|
26
|
+
export * from './x402Executor.js';
|
|
27
|
+
export * from './client.js';
|
|
28
|
+
export * from './policyTemplates.js';
|
|
29
|
+
export * from './evidenceExport.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @onchaindiligence/sdk/commerce
|
|
3
|
+
* ------------------------------
|
|
4
|
+
* The online commerce client (D2.5): open/resume a durable OCD operation,
|
|
5
|
+
* evaluate preflight, execute through an independent executor, and
|
|
6
|
+
* observe/finalize into a signed Commerce Receipt — without hand-assembling
|
|
7
|
+
* onchaindiligence-mcp's D2.4 lifecycle machinery yourself.
|
|
8
|
+
*
|
|
9
|
+
* import { createCommerceClient, MockCommerceExecutor } from '@onchaindiligence/sdk/commerce'
|
|
10
|
+
*
|
|
11
|
+
* Kept separate from the package's root export (the OFFLINE compliance-API
|
|
12
|
+
* client + zero-network attestation verifier) — this module makes network
|
|
13
|
+
* calls and requires a durable recovery store; the root export does not.
|
|
14
|
+
*
|
|
15
|
+
* BROWSER-SAFE by construction: everything here bundles for
|
|
16
|
+
* `--platform=browser` (no Node built-ins). `NodeFileRecoveryStore` is
|
|
17
|
+
* genuinely Node-only (it wraps `node:fs`) and lives at the separate
|
|
18
|
+
* `@onchaindiligence/sdk/commerce/node` subpath instead — importing THIS
|
|
19
|
+
* barrel from a browser bundle can never accidentally pull in `node:fs`.
|
|
20
|
+
*/
|
|
21
|
+
export * from './types.js';
|
|
22
|
+
export * from './results.js';
|
|
23
|
+
export * from './executor.js';
|
|
24
|
+
export * from './recoveryStore.js';
|
|
25
|
+
export * from './mockExecutor.js';
|
|
26
|
+
export * from './x402Executor.js';
|
|
27
|
+
export * from './client.js';
|
|
28
|
+
export * from './policyTemplates.js';
|
|
29
|
+
export * from './evidenceExport.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mockExecutor.ts — a deterministic, no-network CommerceExecutor for the
|
|
3
|
+
* quickstart and tests. It never touches a chain and never moves money;
|
|
4
|
+
* every outcome is scripted so documentation and tests can demonstrate the
|
|
5
|
+
* FULL lifecycle without a live payment, per D2.5's own mandate ("Use a
|
|
6
|
+
* mocked/test executor for the normal quickstart so running documentation
|
|
7
|
+
* does not cost money").
|
|
8
|
+
*/
|
|
9
|
+
import type { CommerceExecutor, PrepareContext, PrepareResult, ExecutionResult, ExecutorRecoveryMode } from './executor.js';
|
|
10
|
+
export type MockOutcomeScript = {
|
|
11
|
+
kind: 'success';
|
|
12
|
+
transactionHash?: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'ambiguous-then-success';
|
|
15
|
+
transactionHash?: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'manual-recovery';
|
|
18
|
+
};
|
|
19
|
+
export interface MockExecutorOptions {
|
|
20
|
+
recoveryMode?: ExecutorRecoveryMode;
|
|
21
|
+
/** What submit()/resume() should do. Defaults to an immediate success. */
|
|
22
|
+
script?: MockOutcomeScript;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A minimal, fully in-memory executor. `prepare()` just records the frozen
|
|
26
|
+
* action; `submit()` follows the configured script exactly once; `resume()`
|
|
27
|
+
* only ever returns what submit() already committed to (or manual recovery,
|
|
28
|
+
* for a 'manual' script) — it never invents a new outcome.
|
|
29
|
+
*/
|
|
30
|
+
export declare class MockCommerceExecutor implements CommerceExecutor {
|
|
31
|
+
readonly id = "mock-executor";
|
|
32
|
+
readonly version = "v1";
|
|
33
|
+
readonly recoveryMode: ExecutorRecoveryMode;
|
|
34
|
+
private readonly script;
|
|
35
|
+
private readonly submittedOnce;
|
|
36
|
+
private readonly outcomes;
|
|
37
|
+
constructor(options?: MockExecutorOptions);
|
|
38
|
+
prepare(context: PrepareContext): Promise<PrepareResult>;
|
|
39
|
+
submit(prepared: PrepareResult): Promise<ExecutionResult>;
|
|
40
|
+
resume(prepared: PrepareResult): Promise<ExecutionResult>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Universal (Node + browser) random hex via Web Crypto -- this file must stay bundle-safe for a browser operator, unlike nodeFileRecoveryStore.ts. */
|
|
2
|
+
function fakeTransactionHash() {
|
|
3
|
+
const bytes = new Uint8Array(32);
|
|
4
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
5
|
+
return '0x' + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A minimal, fully in-memory executor. `prepare()` just records the frozen
|
|
9
|
+
* action; `submit()` follows the configured script exactly once; `resume()`
|
|
10
|
+
* only ever returns what submit() already committed to (or manual recovery,
|
|
11
|
+
* for a 'manual' script) — it never invents a new outcome.
|
|
12
|
+
*/
|
|
13
|
+
export class MockCommerceExecutor {
|
|
14
|
+
id = 'mock-executor';
|
|
15
|
+
version = 'v1';
|
|
16
|
+
recoveryMode;
|
|
17
|
+
script;
|
|
18
|
+
submittedOnce = new Set();
|
|
19
|
+
outcomes = new Map();
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.recoveryMode = options.recoveryMode ?? 'stable-payment-identity';
|
|
22
|
+
this.script = options.script ?? { kind: 'success' };
|
|
23
|
+
}
|
|
24
|
+
async prepare(context) {
|
|
25
|
+
return { clientSubmissionKey: context.clientSubmissionKey, reference: { action: context.action }, preparedAt: new Date().toISOString() };
|
|
26
|
+
}
|
|
27
|
+
async submit(prepared) {
|
|
28
|
+
const key = prepared.clientSubmissionKey;
|
|
29
|
+
if (this.submittedOnce.has(key)) {
|
|
30
|
+
// An orchestrator bug would be the only way to reach this twice for
|
|
31
|
+
// the same key -- report it as ambiguous rather than silently
|
|
32
|
+
// fabricating a second transaction, exactly what a real executor must do.
|
|
33
|
+
return { clientSubmissionKey: key, status: 'submission-ambiguous', reason: 'submit() called twice for the same clientSubmissionKey' };
|
|
34
|
+
}
|
|
35
|
+
this.submittedOnce.add(key);
|
|
36
|
+
if (this.script.kind === 'manual-recovery') {
|
|
37
|
+
const outcome = { clientSubmissionKey: key, status: 'manual-recovery-required', reason: 'mock script: no safe recovery identity' };
|
|
38
|
+
this.outcomes.set(key, outcome);
|
|
39
|
+
return outcome;
|
|
40
|
+
}
|
|
41
|
+
if (this.script.kind === 'ambiguous-then-success') {
|
|
42
|
+
const outcome = { clientSubmissionKey: key, status: 'submission-ambiguous', reason: 'mock script: simulated lost response after submission' };
|
|
43
|
+
this.outcomes.set(key, outcome);
|
|
44
|
+
return outcome;
|
|
45
|
+
}
|
|
46
|
+
const outcome = {
|
|
47
|
+
clientSubmissionKey: key,
|
|
48
|
+
status: 'transaction-known',
|
|
49
|
+
transactionHash: this.script.transactionHash ?? fakeTransactionHash(),
|
|
50
|
+
providerReference: this.id,
|
|
51
|
+
};
|
|
52
|
+
this.outcomes.set(key, outcome);
|
|
53
|
+
return outcome;
|
|
54
|
+
}
|
|
55
|
+
async resume(prepared) {
|
|
56
|
+
const key = prepared.clientSubmissionKey;
|
|
57
|
+
const prior = this.outcomes.get(key);
|
|
58
|
+
if (prior?.status === 'submission-ambiguous' && this.script.kind === 'ambiguous-then-success') {
|
|
59
|
+
// The scripted "resolves on resume" case -- mirrors a real executor
|
|
60
|
+
// that can now confirm what actually happened, e.g. a chain lookup.
|
|
61
|
+
const resolved = {
|
|
62
|
+
clientSubmissionKey: key,
|
|
63
|
+
status: 'transaction-known',
|
|
64
|
+
transactionHash: this.script.transactionHash ?? fakeTransactionHash(),
|
|
65
|
+
providerReference: this.id,
|
|
66
|
+
};
|
|
67
|
+
this.outcomes.set(key, resolved);
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
70
|
+
if (prior)
|
|
71
|
+
return prior;
|
|
72
|
+
return { clientSubmissionKey: key, status: 'manual-recovery-required', reason: 'resume() called before submit() ever ran for this identity' };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type CommerceRecoveryStore, type CommerceRecoveryRecord } from './recoveryStore.js';
|
|
2
|
+
export declare class NodeFileRecoveryStore implements CommerceRecoveryStore {
|
|
3
|
+
private readonly directory;
|
|
4
|
+
private queue;
|
|
5
|
+
constructor(directory: string);
|
|
6
|
+
private pathFor;
|
|
7
|
+
/** Serializes all reads+writes for this store instance so create/update's read-then-write is never interleaved with another call in the same process. */
|
|
8
|
+
private locked;
|
|
9
|
+
private readRaw;
|
|
10
|
+
private writeRaw;
|
|
11
|
+
create(record: Omit<CommerceRecoveryRecord, 'version' | 'createdAt' | 'updatedAt'>): Promise<CommerceRecoveryRecord>;
|
|
12
|
+
load(operationId: string): Promise<CommerceRecoveryRecord | null>;
|
|
13
|
+
update(operationId: string, patch: Partial<Omit<CommerceRecoveryRecord, 'operationId' | 'version'>>, expectedVersion: number): Promise<CommerceRecoveryRecord>;
|
|
14
|
+
findByClientSubmissionKey(clientSubmissionKey: string): Promise<CommerceRecoveryRecord | null>;
|
|
15
|
+
/** Test/example convenience: not part of the interface. */
|
|
16
|
+
_deleteForTests(operationId: string): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nodeFileRecoveryStore.ts — a real, restart-surviving CommerceRecoveryStore
|
|
3
|
+
* for Node (single-process apps, the operator UI, examples, tests).
|
|
4
|
+
*
|
|
5
|
+
* Storage: one JSON file per operation under `directory`, named
|
|
6
|
+
* `<operationId>.json`. Writes are atomic (write to a temp file, then
|
|
7
|
+
* rename) so a crash mid-write can never leave a half-written, corrupt
|
|
8
|
+
* record — the rename either lands or it doesn't.
|
|
9
|
+
*
|
|
10
|
+
* Concurrency: compare-and-swap is enforced at the application level (the
|
|
11
|
+
* version field is checked before every write) and writes within one
|
|
12
|
+
* process are serialized per-operation by an in-memory lock queue, so two
|
|
13
|
+
* concurrent calls in the SAME process can't race past each other between
|
|
14
|
+
* the read and the write. This is NOT a multi-process/multi-machine lock —
|
|
15
|
+
* if your deployment runs more than one instance against the same
|
|
16
|
+
* directory, implement CommerceRecoveryStore against a real database with
|
|
17
|
+
* native CAS (e.g. a WHERE version = $n UPDATE) instead.
|
|
18
|
+
*
|
|
19
|
+
* Secrets (recoveryCredential, finalizationCapability) are stored in this
|
|
20
|
+
* file in plaintext. This is appropriate for local, single-user, file-
|
|
21
|
+
* system-permission-protected use (examples, the local operator) — a real
|
|
22
|
+
* multi-user production deployment should encrypt at rest or use a secrets
|
|
23
|
+
* manager, which is exactly why this is one INTERCHANGEABLE implementation
|
|
24
|
+
* of the interface, not the interface itself.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { randomBytes } from 'node:crypto';
|
|
29
|
+
import { RecoveryRecordExistsError, RecoveryRecordNotFoundError, VersionConflictError, } from './recoveryStore.js';
|
|
30
|
+
function isValidOperationId(operationId) {
|
|
31
|
+
// Matches the shape onchaindiligence-mcp's operation.ts generates
|
|
32
|
+
// (OCD-OP- + base64url); rejected characters could otherwise be used to
|
|
33
|
+
// escape `directory` via a crafted operationId.
|
|
34
|
+
return /^[A-Za-z0-9_-]{1,128}$/.test(operationId);
|
|
35
|
+
}
|
|
36
|
+
export class NodeFileRecoveryStore {
|
|
37
|
+
directory;
|
|
38
|
+
queue = Promise.resolve();
|
|
39
|
+
constructor(directory) {
|
|
40
|
+
this.directory = directory;
|
|
41
|
+
}
|
|
42
|
+
pathFor(operationId) {
|
|
43
|
+
if (!isValidOperationId(operationId))
|
|
44
|
+
throw new TypeError(`invalid operationId for file storage: ${operationId}`);
|
|
45
|
+
return join(this.directory, `${operationId}.json`);
|
|
46
|
+
}
|
|
47
|
+
/** Serializes all reads+writes for this store instance so create/update's read-then-write is never interleaved with another call in the same process. */
|
|
48
|
+
locked(fn) {
|
|
49
|
+
const run = this.queue.then(fn, fn);
|
|
50
|
+
this.queue = run.catch(() => { });
|
|
51
|
+
return run;
|
|
52
|
+
}
|
|
53
|
+
async readRaw(operationId) {
|
|
54
|
+
try {
|
|
55
|
+
const text = await readFile(this.pathFor(operationId), 'utf8');
|
|
56
|
+
return JSON.parse(text);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err?.code === 'ENOENT')
|
|
60
|
+
return null;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async writeRaw(operationId, record) {
|
|
65
|
+
await mkdir(this.directory, { recursive: true });
|
|
66
|
+
const finalPath = this.pathFor(operationId);
|
|
67
|
+
const tempPath = join(this.directory, `.${operationId}.${randomBytes(4).toString('hex')}.tmp`);
|
|
68
|
+
await writeFile(tempPath, JSON.stringify(record, null, 2), 'utf8');
|
|
69
|
+
await rename(tempPath, finalPath); // atomic on the same filesystem
|
|
70
|
+
}
|
|
71
|
+
async create(record) {
|
|
72
|
+
return this.locked(async () => {
|
|
73
|
+
const existing = await this.readRaw(record.operationId);
|
|
74
|
+
if (existing)
|
|
75
|
+
throw new RecoveryRecordExistsError(record.operationId);
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
const full = { ...record, version: 1, createdAt: now, updatedAt: now };
|
|
78
|
+
await this.writeRaw(record.operationId, full);
|
|
79
|
+
return full;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async load(operationId) {
|
|
83
|
+
return this.locked(() => this.readRaw(operationId));
|
|
84
|
+
}
|
|
85
|
+
async update(operationId, patch, expectedVersion) {
|
|
86
|
+
return this.locked(async () => {
|
|
87
|
+
const existing = await this.readRaw(operationId);
|
|
88
|
+
if (!existing)
|
|
89
|
+
throw new RecoveryRecordNotFoundError(operationId);
|
|
90
|
+
if (existing.version !== expectedVersion)
|
|
91
|
+
throw new VersionConflictError(operationId, expectedVersion, existing.version);
|
|
92
|
+
const updated = { ...existing, ...patch, version: existing.version + 1, updatedAt: new Date().toISOString() };
|
|
93
|
+
await this.writeRaw(operationId, updated);
|
|
94
|
+
return updated;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async findByClientSubmissionKey(clientSubmissionKey) {
|
|
98
|
+
// No index -- a real production store backed by a database should add
|
|
99
|
+
// one. Scoped to Node's readdir, acceptable for the small, local,
|
|
100
|
+
// single-operator-at-a-time use this implementation targets.
|
|
101
|
+
return this.locked(async () => {
|
|
102
|
+
const { readdir } = await import('node:fs/promises');
|
|
103
|
+
let files;
|
|
104
|
+
try {
|
|
105
|
+
files = await readdir(this.directory);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (err?.code === 'ENOENT')
|
|
109
|
+
return null;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
if (!file.endsWith('.json') || file.startsWith('.'))
|
|
114
|
+
continue;
|
|
115
|
+
const record = await this.readRaw(file.slice(0, -'.json'.length));
|
|
116
|
+
if (record?.clientSubmissionKey === clientSubmissionKey)
|
|
117
|
+
return record;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Test/example convenience: not part of the interface. */
|
|
123
|
+
async _deleteForTests(operationId) {
|
|
124
|
+
await unlink(this.pathFor(operationId)).catch(() => { });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policyTemplates.ts — a SMALL set of versioned starter policy templates
|
|
3
|
+
* (D2.5, Section 9). These are convenience functions that produce ordinary
|
|
4
|
+
* strict CommercePolicy objects — they introduce NO new policy semantics,
|
|
5
|
+
* NO natural-language interpretation, and NO authorization authority. Every
|
|
6
|
+
* `policy` object returned here is exactly what a developer could have
|
|
7
|
+
* hand-written against onchaindiligence-mcp's own strict, closed-schema
|
|
8
|
+
* policy parser (which rejects any unrecognized field outright) — so the
|
|
9
|
+
* version is returned ALONGSIDE the policy, never embedded inside it; a
|
|
10
|
+
* template_version field inside `policy` itself would be rejected as an
|
|
11
|
+
* unrecognized field by the server.
|
|
12
|
+
*/
|
|
13
|
+
import type { CommercePolicy } from './types.js';
|
|
14
|
+
export declare const POLICY_TEMPLATE_VERSION = "onchaindiligence.policy-template.v1";
|
|
15
|
+
export interface PolicyTemplateResult {
|
|
16
|
+
policy: CommercePolicy;
|
|
17
|
+
templateVersion: typeof POLICY_TEMPLATE_VERSION;
|
|
18
|
+
templateName: string;
|
|
19
|
+
}
|
|
20
|
+
/** A. Bounded API purchase: caps amount, network, and asset; no recipient/origin restriction. */
|
|
21
|
+
export declare function apiPurchasePolicy(params: {
|
|
22
|
+
maxAmount: string;
|
|
23
|
+
allowedNetwork: string;
|
|
24
|
+
allowedAsset: string;
|
|
25
|
+
}): PolicyTemplateResult;
|
|
26
|
+
/**
|
|
27
|
+
* B. Approval required above threshold: this template alone cannot express
|
|
28
|
+
* "ALLOW below X, REQUIRE_APPROVAL above X" as a single policy object — OCD
|
|
29
|
+
* policy evaluation is FAIL-closed on max_amount (exceeding it is BLOCK, not
|
|
30
|
+
* REQUIRE_APPROVAL; see onchaindiligence-mcp's evaluatePreflightPolicy). This
|
|
31
|
+
* helper instead returns the policy to use for the "approval" tier: no
|
|
32
|
+
* amount cap at all (acknowledged explicitly), so amount-based judgment is
|
|
33
|
+
* left entirely to whatever separate human/approval process the developer's
|
|
34
|
+
* own application wires up above OCD -- OCD's role stays what it always is
|
|
35
|
+
* (policy evaluation), never a stand-in for that approval step.
|
|
36
|
+
*/
|
|
37
|
+
export declare function approvalAboveThresholdPolicy(params: {
|
|
38
|
+
allowedNetwork: string;
|
|
39
|
+
allowedAsset: string;
|
|
40
|
+
expectedRecipient?: string | null;
|
|
41
|
+
}): PolicyTemplateResult;
|
|
42
|
+
/** C. Fixed-recipient bounded payment: caps amount AND pins the exact recipient — the tightest of the three templates. */
|
|
43
|
+
export declare function fixedRecipientPolicy(params: {
|
|
44
|
+
maxAmount: string;
|
|
45
|
+
allowedNetwork: string;
|
|
46
|
+
allowedAsset: string;
|
|
47
|
+
expectedRecipient: string;
|
|
48
|
+
}): PolicyTemplateResult;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const POLICY_TEMPLATE_VERSION = 'onchaindiligence.policy-template.v1';
|
|
2
|
+
/** A. Bounded API purchase: caps amount, network, and asset; no recipient/origin restriction. */
|
|
3
|
+
export function apiPurchasePolicy(params) {
|
|
4
|
+
return {
|
|
5
|
+
policy: {
|
|
6
|
+
max_amount: params.maxAmount,
|
|
7
|
+
allowed_networks: [params.allowedNetwork],
|
|
8
|
+
allowed_assets: [params.allowedAsset],
|
|
9
|
+
expected_recipient: null,
|
|
10
|
+
allowed_resource_origins: null,
|
|
11
|
+
},
|
|
12
|
+
templateVersion: POLICY_TEMPLATE_VERSION,
|
|
13
|
+
templateName: 'bounded-api-purchase',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* B. Approval required above threshold: this template alone cannot express
|
|
18
|
+
* "ALLOW below X, REQUIRE_APPROVAL above X" as a single policy object — OCD
|
|
19
|
+
* policy evaluation is FAIL-closed on max_amount (exceeding it is BLOCK, not
|
|
20
|
+
* REQUIRE_APPROVAL; see onchaindiligence-mcp's evaluatePreflightPolicy). This
|
|
21
|
+
* helper instead returns the policy to use for the "approval" tier: no
|
|
22
|
+
* amount cap at all (acknowledged explicitly), so amount-based judgment is
|
|
23
|
+
* left entirely to whatever separate human/approval process the developer's
|
|
24
|
+
* own application wires up above OCD -- OCD's role stays what it always is
|
|
25
|
+
* (policy evaluation), never a stand-in for that approval step.
|
|
26
|
+
*/
|
|
27
|
+
export function approvalAboveThresholdPolicy(params) {
|
|
28
|
+
return {
|
|
29
|
+
policy: {
|
|
30
|
+
max_amount: null,
|
|
31
|
+
allowed_networks: [params.allowedNetwork],
|
|
32
|
+
allowed_assets: [params.allowedAsset],
|
|
33
|
+
expected_recipient: params.expectedRecipient ?? null,
|
|
34
|
+
allowed_resource_origins: null,
|
|
35
|
+
acknowledge_unconstrained: true,
|
|
36
|
+
},
|
|
37
|
+
templateVersion: POLICY_TEMPLATE_VERSION,
|
|
38
|
+
templateName: 'approval-above-threshold',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** C. Fixed-recipient bounded payment: caps amount AND pins the exact recipient — the tightest of the three templates. */
|
|
42
|
+
export function fixedRecipientPolicy(params) {
|
|
43
|
+
return {
|
|
44
|
+
policy: {
|
|
45
|
+
max_amount: params.maxAmount,
|
|
46
|
+
allowed_networks: [params.allowedNetwork],
|
|
47
|
+
allowed_assets: [params.allowedAsset],
|
|
48
|
+
expected_recipient: params.expectedRecipient,
|
|
49
|
+
allowed_resource_origins: null,
|
|
50
|
+
},
|
|
51
|
+
templateVersion: POLICY_TEMPLATE_VERSION,
|
|
52
|
+
templateName: 'fixed-recipient-bounded-payment',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recoveryStore.ts — durable recovery abstraction for the commerce client.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONE thing standing between "lost my HTTP response" and "paid
|
|
5
|
+
* twice" or "sent a duplicate merchant payment" on the CLIENT side — the
|
|
6
|
+
* mirror image of what onchaindiligence-mcp's own lifecycle_steps /
|
|
7
|
+
* execution_bindings tables do server-side (D2.4). A developer's application
|
|
8
|
+
* MUST persist a record here BEFORE calling anything that could move money
|
|
9
|
+
* (preflight, executor.prepare, executor.submit) so a crash between "we
|
|
10
|
+
* asked" and "we got an answer" has something durable to resume from.
|
|
11
|
+
*
|
|
12
|
+
* `create`/`update` use explicit optimistic concurrency (a version counter)
|
|
13
|
+
* rather than silent last-write-wins, so two concurrent processes touching
|
|
14
|
+
* the same operation can never silently clobber each other's state — one of
|
|
15
|
+
* them gets a VersionConflictError and must reload and retry.
|
|
16
|
+
*
|
|
17
|
+
* The in-memory implementation exists ONLY for tests and throwaway scripts.
|
|
18
|
+
* It is explicitly NOT advertised as production-safe (it does not survive a
|
|
19
|
+
* process restart, which defeats the entire point of this interface) — see
|
|
20
|
+
* NodeFileRecoveryStore for a real, restart-surviving option, and implement
|
|
21
|
+
* this interface against your own database for a multi-instance deployment.
|
|
22
|
+
*/
|
|
23
|
+
export interface CommerceRecoveryRecord {
|
|
24
|
+
operationId: string;
|
|
25
|
+
/** The OCD-issued recovery credential — required to call GET /operations/:id, POST .../execution-bindings, POST .../finalize. Never log this. */
|
|
26
|
+
recoveryCredential: string;
|
|
27
|
+
/** Optimistic-concurrency version. Starts at 1; every update() must supply the version it read and gets back the new one. */
|
|
28
|
+
version: number;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
preflightReceiptId: string | null;
|
|
32
|
+
/** The one-time finalization capability token, when known. Never log this. */
|
|
33
|
+
finalizationCapability: string | null;
|
|
34
|
+
finalizationCapabilityExpiresAt: string | null;
|
|
35
|
+
executionRequestId: string | null;
|
|
36
|
+
/** Caller-chosen idempotency key for the executor's submission attempt — see executor.ts. */
|
|
37
|
+
clientSubmissionKey: string | null;
|
|
38
|
+
/** The `id` of the CommerceExecutor used for the current/last execution attempt — used only to label the finalize call's execution_provider field. */
|
|
39
|
+
executorId: string | null;
|
|
40
|
+
transactionHash: string | null;
|
|
41
|
+
/** Free-form local phase label for the developer's own UI/logging — never trusted as the source of truth (the server's /operations/:id status is). */
|
|
42
|
+
localPhase: string;
|
|
43
|
+
}
|
|
44
|
+
export declare class RecoveryRecordExistsError extends Error {
|
|
45
|
+
constructor(operationId: string);
|
|
46
|
+
}
|
|
47
|
+
export declare class RecoveryRecordNotFoundError extends Error {
|
|
48
|
+
constructor(operationId: string);
|
|
49
|
+
}
|
|
50
|
+
export declare class VersionConflictError extends Error {
|
|
51
|
+
constructor(operationId: string, expected: number, actual: number);
|
|
52
|
+
}
|
|
53
|
+
export interface CommerceRecoveryStore {
|
|
54
|
+
/** Atomic create. Throws RecoveryRecordExistsError if the operationId is already present. */
|
|
55
|
+
create(record: Omit<CommerceRecoveryRecord, 'version' | 'createdAt' | 'updatedAt'>): Promise<CommerceRecoveryRecord>;
|
|
56
|
+
load(operationId: string): Promise<CommerceRecoveryRecord | null>;
|
|
57
|
+
/** Compare-and-swap update: `expectedVersion` must match the currently-stored version, or this throws VersionConflictError without applying the patch. */
|
|
58
|
+
update(operationId: string, patch: Partial<Omit<CommerceRecoveryRecord, 'operationId' | 'version'>>, expectedVersion: number): Promise<CommerceRecoveryRecord>;
|
|
59
|
+
/** Convenience lookup used by resume-after-restart flows that only know the executor's own idempotency key, not the operation id. Optional: stores that can't index this may return null always. */
|
|
60
|
+
findByClientSubmissionKey(clientSubmissionKey: string): Promise<CommerceRecoveryRecord | null>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Volatile, single-process, TEST/EXAMPLE-ONLY implementation. Does not
|
|
64
|
+
* survive a restart -- using this in production silently defeats every
|
|
65
|
+
* "resume after crash/restart" guarantee this interface exists to provide.
|
|
66
|
+
*/
|
|
67
|
+
export declare class InMemoryRecoveryStore implements CommerceRecoveryStore {
|
|
68
|
+
private readonly records;
|
|
69
|
+
create(record: Omit<CommerceRecoveryRecord, 'version' | 'createdAt' | 'updatedAt'>): Promise<CommerceRecoveryRecord>;
|
|
70
|
+
load(operationId: string): Promise<CommerceRecoveryRecord | null>;
|
|
71
|
+
update(operationId: string, patch: Partial<Omit<CommerceRecoveryRecord, 'operationId' | 'version'>>, expectedVersion: number): Promise<CommerceRecoveryRecord>;
|
|
72
|
+
findByClientSubmissionKey(clientSubmissionKey: string): Promise<CommerceRecoveryRecord | null>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recoveryStore.ts — durable recovery abstraction for the commerce client.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONE thing standing between "lost my HTTP response" and "paid
|
|
5
|
+
* twice" or "sent a duplicate merchant payment" on the CLIENT side — the
|
|
6
|
+
* mirror image of what onchaindiligence-mcp's own lifecycle_steps /
|
|
7
|
+
* execution_bindings tables do server-side (D2.4). A developer's application
|
|
8
|
+
* MUST persist a record here BEFORE calling anything that could move money
|
|
9
|
+
* (preflight, executor.prepare, executor.submit) so a crash between "we
|
|
10
|
+
* asked" and "we got an answer" has something durable to resume from.
|
|
11
|
+
*
|
|
12
|
+
* `create`/`update` use explicit optimistic concurrency (a version counter)
|
|
13
|
+
* rather than silent last-write-wins, so two concurrent processes touching
|
|
14
|
+
* the same operation can never silently clobber each other's state — one of
|
|
15
|
+
* them gets a VersionConflictError and must reload and retry.
|
|
16
|
+
*
|
|
17
|
+
* The in-memory implementation exists ONLY for tests and throwaway scripts.
|
|
18
|
+
* It is explicitly NOT advertised as production-safe (it does not survive a
|
|
19
|
+
* process restart, which defeats the entire point of this interface) — see
|
|
20
|
+
* NodeFileRecoveryStore for a real, restart-surviving option, and implement
|
|
21
|
+
* this interface against your own database for a multi-instance deployment.
|
|
22
|
+
*/
|
|
23
|
+
export class RecoveryRecordExistsError extends Error {
|
|
24
|
+
constructor(operationId) {
|
|
25
|
+
super(`a recovery record for operation ${operationId} already exists`);
|
|
26
|
+
this.name = 'RecoveryRecordExistsError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class RecoveryRecordNotFoundError extends Error {
|
|
30
|
+
constructor(operationId) {
|
|
31
|
+
super(`no recovery record found for operation ${operationId}`);
|
|
32
|
+
this.name = 'RecoveryRecordNotFoundError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class VersionConflictError extends Error {
|
|
36
|
+
constructor(operationId, expected, actual) {
|
|
37
|
+
super(`recovery record for ${operationId} was updated concurrently (expected version ${expected}, found ${actual}) -- reload and retry`);
|
|
38
|
+
this.name = 'VersionConflictError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Volatile, single-process, TEST/EXAMPLE-ONLY implementation. Does not
|
|
43
|
+
* survive a restart -- using this in production silently defeats every
|
|
44
|
+
* "resume after crash/restart" guarantee this interface exists to provide.
|
|
45
|
+
*/
|
|
46
|
+
export class InMemoryRecoveryStore {
|
|
47
|
+
records = new Map();
|
|
48
|
+
async create(record) {
|
|
49
|
+
if (this.records.has(record.operationId))
|
|
50
|
+
throw new RecoveryRecordExistsError(record.operationId);
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const full = { ...record, version: 1, createdAt: now, updatedAt: now };
|
|
53
|
+
this.records.set(record.operationId, full);
|
|
54
|
+
return { ...full };
|
|
55
|
+
}
|
|
56
|
+
async load(operationId) {
|
|
57
|
+
const found = this.records.get(operationId);
|
|
58
|
+
return found ? { ...found } : null;
|
|
59
|
+
}
|
|
60
|
+
async update(operationId, patch, expectedVersion) {
|
|
61
|
+
const existing = this.records.get(operationId);
|
|
62
|
+
if (!existing)
|
|
63
|
+
throw new RecoveryRecordNotFoundError(operationId);
|
|
64
|
+
if (existing.version !== expectedVersion)
|
|
65
|
+
throw new VersionConflictError(operationId, expectedVersion, existing.version);
|
|
66
|
+
const updated = { ...existing, ...patch, version: existing.version + 1, updatedAt: new Date().toISOString() };
|
|
67
|
+
this.records.set(operationId, updated);
|
|
68
|
+
return { ...updated };
|
|
69
|
+
}
|
|
70
|
+
async findByClientSubmissionKey(clientSubmissionKey) {
|
|
71
|
+
for (const record of this.records.values()) {
|
|
72
|
+
if (record.clientSubmissionKey === clientSubmissionKey)
|
|
73
|
+
return { ...record };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|