402mob-merchant-sdk 0.4.2 → 0.5.0

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.
@@ -0,0 +1,187 @@
1
+ import { protectedSchemas } from './protected-work-schemas.js';
2
+ export * from './protected-work-types.js';
3
+ const USDC = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913';
4
+ const hash = /^0x[0-9a-f]{64}$/;
5
+ const address = /^0x(?!0{40}$)[0-9a-fA-F]{40}$/;
6
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
7
+ const canonical = (v) => JSON.stringify(v, (_key, item) => record(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
8
+ function matches(s, v, depth = 0) {
9
+ if (depth > 128)
10
+ return false;
11
+ if ('const' in s && canonical(s.const) !== canonical(v))
12
+ return false;
13
+ if (s.enum && !s.enum.includes(v))
14
+ return false;
15
+ if (s.anyOf && !s.anyOf.some(c => matches(c, v, depth + 1)))
16
+ return false;
17
+ if (s.oneOf && s.oneOf.filter(c => matches(c, v, depth + 1)).length !== 1)
18
+ return false;
19
+ if (Array.isArray(s.type))
20
+ return s.type.some(type => matches({ ...s, type }, v, depth + 1));
21
+ switch (s.type) {
22
+ case 'null': return v === null;
23
+ case 'boolean': return typeof v === 'boolean';
24
+ case 'number':
25
+ case 'integer': return typeof v === 'number' && Number.isFinite(v) && (s.type !== 'integer' || Number.isSafeInteger(v)) && (s.minimum === undefined || v >= s.minimum) && (s.maximum === undefined || v <= s.maximum) && (s.exclusiveMinimum === undefined || v > s.exclusiveMinimum);
26
+ case 'string': return typeof v === 'string' && (s.minLength === undefined || [...v].length >= s.minLength) && (s.maxLength === undefined || [...v].length <= s.maxLength) && (!s.pattern || new RegExp(s.pattern).test(v)) && (!s.format || s.format === 'date-time' && /^\d{4}-\d\d-\d\dT/.test(v) && Number.isFinite(Date.parse(v)));
27
+ case 'array': return Array.isArray(v) && (s.minItems === undefined || v.length >= s.minItems) && (s.maxItems === undefined || v.length <= s.maxItems) && v.every((item, i) => { const child = s.prefixItems?.[i] ?? s.items ?? {}; return child !== false && (child === true || matches(child, item, depth + 1)); });
28
+ case 'object': return record(v) && (s.required ?? []).every(k => Object.hasOwn(v, k)) && Object.entries(v).every(([k, item]) => s.properties && Object.hasOwn(s.properties, k) ? matches(s.properties[k], item, depth + 1) : s.additionalProperties !== false);
29
+ default: return true;
30
+ }
31
+ }
32
+ export class ProtectedWorkApiError extends Error {
33
+ status;
34
+ code;
35
+ constructor(status, code, message) {
36
+ super(message);
37
+ this.status = status;
38
+ this.code = code;
39
+ this.name = 'ProtectedWorkApiError';
40
+ }
41
+ }
42
+ /** HTTP transport only. No keys, merchant bearer tokens, wallet signing or transaction broadcasting. */
43
+ export class ProtectedWorkClient {
44
+ origin;
45
+ contract;
46
+ verifierHash;
47
+ fetcher;
48
+ constructor(options = {}) {
49
+ const url = new URL(options.baseUrl ?? 'https://evidence.contact');
50
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || !(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
51
+ throw new TypeError('Provide an exact HTTPS origin, or HTTP loopback for local tests.');
52
+ if (options.contract !== undefined && !address.test(options.contract))
53
+ throw new TypeError('Invalid escrow contract address.');
54
+ if (options.verifierHash !== undefined && !hash.test(options.verifierHash))
55
+ throw new TypeError('Invalid pinned verifier hash.');
56
+ this.origin = url.origin;
57
+ this.contract = options.contract?.toLowerCase();
58
+ this.verifierHash = options.verifierHash;
59
+ this.fetcher = options.fetch ?? globalThis.fetch;
60
+ }
61
+ getCapabilities() { return this.call('', 'GET', undefined, undefined, 'ProtectedWorkCapability'); }
62
+ prepare(input) { return this.call('/prepare', 'POST', input, 'ProtectedWorkPrepareRequest', 'ProtectedWorkPrepareResponse'); }
63
+ prepareFunding(input) { return this.call('/funding', 'POST', input, 'ProtectedWorkFundingRequest', 'ProtectedWorkFundingResponse'); }
64
+ getJob(id) { return this.job(id, '', 'GET', undefined, undefined, 'ProtectedWorkStatus'); }
65
+ createReadChallenge(id, input) { return this.job(id, '/challenges', 'POST', input, 'ProtectedWorkChallengeRequest', 'ProtectedWorkChallenge'); }
66
+ createCandidateSigning(id, input) { return this.job(id, '/candidate-signing', 'POST', input, 'ProtectedWorkCandidateRequest', 'ProtectedWorkCandidateSigning'); }
67
+ submitCandidate(id, input) { return this.job(id, '/submissions', 'POST', input, 'ProtectedWorkSubmissionRequest', 'ProtectedWorkSubmissionResponse'); }
68
+ attachReplicas(id, input) { return this.job(id, '/replicas', 'POST', input, 'ProtectedWorkReplicasRequest', 'ProtectedWorkReplicasResponse'); }
69
+ evaluate(id) { return this.job(id, '/evaluation', 'POST', {}, 'ProtectedWorkEvaluationRequest', 'ProtectedWorkEvaluationResponse'); }
70
+ readResult(id, input) { return this.job(id, '/result', 'POST', input, 'ProtectedWorkResultRequest', 'ProtectedWorkResult'); }
71
+ /** With no signature, requests a draft. A signed action may be relayed by a configured server. */
72
+ requestAction(id, input) { return this.job(id, '/actions', 'POST', input, 'ProtectedWorkActionRequest', 'ProtectedWorkActionResponse'); }
73
+ submitAppeal(id, input) { return this.job(id, '/appeals', 'POST', input, 'ProtectedWorkAppealRequest', 'ProtectedWorkAppealResponse'); }
74
+ job(id, suffix, method, input, inputSchema, outputSchema) {
75
+ if (!hash.test(id))
76
+ throw new TypeError('Protected job IDs are lowercase 32-byte hashes, not operation UUIDs.');
77
+ return this.call('/' + id + suffix, method, input, inputSchema, outputSchema, id);
78
+ }
79
+ invalid() { throw new ProtectedWorkApiError(200, 'invalid_response', 'Protected-work response does not match the pinned request. Preserve the job and signed transaction; do not repeat funding.'); }
80
+ transaction(value, allowApproval = false, amount) {
81
+ if (!record(value) || value.chainId !== 8453 || value.value !== '0' || typeof value.to !== 'string' || typeof value.data !== 'string' || !/^0x(?:[a-fA-F0-9]{2})+$/.test(value.data))
82
+ this.invalid();
83
+ if (allowApproval) {
84
+ if (value.to.toLowerCase() !== USDC || !amount || value.data.toLowerCase() !== '0x095ea7b3' + this.contract.slice(2).padStart(64, '0') + BigInt(amount).toString(16).padStart(64, '0'))
85
+ this.invalid();
86
+ }
87
+ else if (value.to.toLowerCase() !== this.contract)
88
+ this.invalid();
89
+ }
90
+ async call(suffix, method, input, inputSchema, outputSchema, id) {
91
+ if (suffix && (!this.contract || !this.verifierHash))
92
+ throw new TypeError('Pin contract and verifierHash before using protected-work methods.');
93
+ const body = input === undefined ? undefined : JSON.stringify(input);
94
+ if (body !== undefined && (new TextEncoder().encode(body).length > 65536 || inputSchema && !matches(protectedSchemas[inputSchema], JSON.parse(body))))
95
+ throw new TypeError('Protected-work input does not match its schema or exceeds 64 KiB.');
96
+ if (record(input) && record(input.terms) && input.terms.verifierHash !== undefined && input.terms.verifierHash !== this.verifierHash)
97
+ throw new TypeError('Funding verifier differs from the pinned verifier.');
98
+ const response = await this.fetcher(this.origin + '/v1/protected-work' + suffix, { method, redirect: 'error', credentials: 'omit', headers: { Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, ...(body === undefined ? {} : { body }) });
99
+ let payload;
100
+ try {
101
+ if (!response.body)
102
+ throw new Error();
103
+ const reader = response.body.getReader(), chunks = [];
104
+ let size = 0;
105
+ try {
106
+ while (true) {
107
+ const item = await reader.read();
108
+ if (item.done)
109
+ break;
110
+ size += item.value.length;
111
+ if (size > 65536) {
112
+ await reader.cancel();
113
+ throw new Error();
114
+ }
115
+ chunks.push(item.value);
116
+ }
117
+ }
118
+ finally {
119
+ reader.releaseLock();
120
+ }
121
+ const bytes = new Uint8Array(size);
122
+ let offset = 0;
123
+ for (const chunk of chunks) {
124
+ bytes.set(chunk, offset);
125
+ offset += chunk.length;
126
+ }
127
+ payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
128
+ }
129
+ catch {
130
+ throw new ProtectedWorkApiError(response.status, response.ok ? 'invalid_response' : 'http_error', 'Unreadable or oversized response; preserve recovery data.');
131
+ }
132
+ if (!response.ok)
133
+ throw new ProtectedWorkApiError(response.status, record(payload) && typeof payload.error === 'string' ? payload.error : 'http_error', 'Protected-work request was rejected. Inspect status/code and the existing job before retrying.');
134
+ if (!matches(protectedSchemas[outputSchema], payload) || !record(payload))
135
+ this.invalid();
136
+ if (id && 'jobId' in payload && payload.jobId !== id)
137
+ this.invalid();
138
+ if (record(payload.relay) && payload.relay.queued !== (payload.relay.id !== null))
139
+ this.invalid();
140
+ if (suffix === '') {
141
+ if (payload.protocol !== '402mob.protected-json-work.v3' || payload.network !== 'eip155:8453' || (typeof payload.asset !== 'string' || payload.asset.toLowerCase() !== USDC) || this.verifierHash && payload.verifierHash !== this.verifierHash)
142
+ this.invalid();
143
+ }
144
+ if (record(payload.terms) && payload.terms.verifierHash !== this.verifierHash)
145
+ this.invalid();
146
+ for (const key of ['transaction', 'submissionTransaction', 'evaluationTransaction', 'refundTransaction', 'finalizeTransaction'])
147
+ if (payload[key] !== undefined && payload[key] !== null)
148
+ this.transaction(payload[key]);
149
+ if (Array.isArray(payload.transactions)) {
150
+ const amount = record(input) && record(input.terms) ? String(input.terms.amount) : undefined;
151
+ payload.transactions.forEach((tx, i) => this.transaction(tx, i === 0, amount));
152
+ }
153
+ for (const key of ['signing', 'sellerSigning'])
154
+ if (payload[key] !== undefined && payload[key] !== null) {
155
+ const signing = payload[key];
156
+ if (!record(signing) || !record(signing.domain) || !record(signing.message))
157
+ this.invalid();
158
+ const d = signing.domain;
159
+ if (d.name !== '402Mob Protected Work' || d.version !== '3' || d.chainId !== 8453 || typeof d.verifyingContract !== 'string' || d.verifyingContract.toLowerCase() !== this.contract)
160
+ this.invalid();
161
+ if (id && signing.message.jobId !== id)
162
+ this.invalid();
163
+ if (suffix.endsWith('/candidate-signing') && record(input) && (signing.primaryType !== 'Candidate' || signing.message.attempt !== input.attempt || signing.message.resultHash !== input.resultHash))
164
+ this.invalid();
165
+ if (suffix.endsWith('/actions') && record(input) && signing.primaryType !== { receipt: 'Receipt', accept: 'Accept', challenge: 'Challenge' }[String(input.action)])
166
+ this.invalid();
167
+ }
168
+ if (suffix.endsWith('/actions') && record(input)) {
169
+ const draft = input.signature === undefined && ['receipt', 'accept', 'challenge'].includes(String(input.action));
170
+ if (draft ? (payload.signing === null || payload.transaction !== null) : (payload.signing !== null || payload.transaction === null))
171
+ this.invalid();
172
+ }
173
+ if (suffix === '/prepare') {
174
+ if (!record(input) || !record(input.terms) || !record(payload.sellerSigning) || canonical(payload.terms) !== canonical({ ...input.terms, workHash: payload.workHash, verifierHash: this.verifierHash }) || canonical(payload.sellerSigning.message) !== canonical(payload.terms))
175
+ this.invalid();
176
+ }
177
+ if (suffix.endsWith('/challenges')) {
178
+ if (!record(input) || typeof input.wallet !== 'string' || typeof payload.expiresAt !== 'number' || payload.wallet !== input.wallet || payload.action !== 'read')
179
+ this.invalid();
180
+ const now = Math.floor(Date.now() / 1000);
181
+ const expected = '402Mob protected JSON work read proof\n' + canonical({ protocol: '402mob.protected-json-work.v3', origin: this.origin, contract: this.contract, chainId: 8453, jobId: id, action: 'read', wallet: input.wallet.toLowerCase(), expiresAt: payload.expiresAt });
182
+ if (payload.message !== expected || payload.expiresAt <= now || payload.expiresAt > now + 120)
183
+ this.invalid();
184
+ }
185
+ return payload;
186
+ }
187
+ }
@@ -0,0 +1,21 @@
1
+ import {ProtectedWorkClient, type ProtectedWorkClientOptions, type ProtectedWorkPrepareRequest} from '../dist/index.js';
2
+
3
+ // Supply a reviewed origin, contract and verifier hash; never copy a test contract into production.
4
+ export async function prepareJsonWork(options: ProtectedWorkClientOptions, input: ProtectedWorkPrepareRequest) {
5
+ const client = new ProtectedWorkClient(options);
6
+ const capability = await client.getCapabilities();
7
+ if (!capability.configured) throw new Error('Protected work is disabled on this server.');
8
+ // The caller reviews the returned terms and EIP-712 data before asking its seller wallet to sign.
9
+ return client.prepare(input);
10
+ }
11
+
12
+ export async function readCandidate(options: ProtectedWorkClientOptions, jobId: string, wallet: string,
13
+ signMessage: (exactMessage: string) => Promise<string>) {
14
+ const client = new ProtectedWorkClient(options);
15
+ const challenge = await client.createReadChallenge(jobId, {action: 'read', wallet});
16
+ // The client has checked the challenge's origin, contract, wallet, job and expiry.
17
+ const signature = await signMessage(challenge.message);
18
+ return client.readResult(jobId, {proof: {wallet, expiresAt: challenge.expiresAt, signature}});
19
+ }
20
+
21
+ // A read or receipt is not acceptance. No example broadcasts, funds, signs acceptance or retries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "402mob-merchant-sdk",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript client for resolving agent resources and enrolling and operating 402Mob merchants.",
5
5
  "license": "MIT",
6
6
  "repository": {