402mob-merchant-sdk 0.4.2 → 0.6.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.
- package/CHANGELOG.md +24 -1
- package/README.md +53 -2
- package/dist/index.d.ts +264 -1
- package/dist/index.js +239 -0
- package/dist/protected-inspection-schemas.d.ts +2 -0
- package/dist/protected-inspection-schemas.js +3117 -0
- package/dist/protected-inspection-types.d.ts +623 -0
- package/dist/protected-inspection-types.js +1 -0
- package/dist/protected-inspection.d.ts +42 -0
- package/dist/protected-inspection.js +197 -0
- package/dist/protected-work-schemas.d.ts +2 -0
- package/dist/protected-work-schemas.js +2558 -0
- package/dist/protected-work-types.d.ts +524 -0
- package/dist/protected-work-types.js +1 -0
- package/dist/protected-work.d.ts +40 -0
- package/dist/protected-work.js +187 -0
- package/examples/protected-inspection-service.ts +31 -0
- package/examples/protected-work.ts +21 -0
- package/examples/respond-to-work-request.ts +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { inspectionSchemas } from './protected-inspection-schemas.js';
|
|
2
|
+
export * from './protected-inspection-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 === 'uuid' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(v) || s.format === 'uri' && /^https:\/\//.test(v) || 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 ProtectedInspectionApiError 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 = 'ProtectedInspectionApiError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** HTTP transport only. No keys, merchant bearer tokens, wallet signing or transaction broadcasting. */
|
|
43
|
+
export class ProtectedInspectionClient {
|
|
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, 'ProtectedInspectionCapability'); }
|
|
62
|
+
prepare(input) { return this.call('/prepare', 'POST', input, 'ProtectedInspectionPrepareRequest', 'ProtectedInspectionPrepareResponse'); }
|
|
63
|
+
prepareFunding(input) { return this.call('/funding', 'POST', input, 'ProtectedInspectionFundingRequest', 'ProtectedInspectionFundingResponse'); }
|
|
64
|
+
getJob(id) { return this.job(id, '', 'GET', undefined, undefined, 'ProtectedInspectionStatus'); }
|
|
65
|
+
createReadChallenge(id, input) { return this.job(id, '/challenges', 'POST', input, 'ProtectedInspectionChallengeRequest', 'ProtectedInspectionChallenge'); }
|
|
66
|
+
createCandidateSigning(id, input) { return this.job(id, '/candidate-signing', 'POST', input, 'ProtectedInspectionCandidateRequest', 'ProtectedInspectionCandidateSigning'); }
|
|
67
|
+
submitCandidate(id, input) { return this.job(id, '/submissions', 'POST', input, 'ProtectedInspectionSubmissionRequest', 'ProtectedInspectionSubmissionResponse'); }
|
|
68
|
+
attachReplicas(id, input) { return this.job(id, '/replicas', 'POST', input, 'ProtectedInspectionReplicasRequest', 'ProtectedInspectionReplicasResponse'); }
|
|
69
|
+
evaluate(id) { return this.job(id, '/evaluation', 'POST', {}, 'ProtectedInspectionEvaluationRequest', 'ProtectedInspectionEvaluationResponse'); }
|
|
70
|
+
readResult(id, input) { return this.job(id, '/result', 'POST', input, 'ProtectedInspectionResultRequest', 'ProtectedInspectionResult'); }
|
|
71
|
+
/** With no signature, requests a draft. Submit the returned transaction yourself; inspections have no hosted relay. */
|
|
72
|
+
requestAction(id, input) { return this.job(id, '/actions', 'POST', input, 'ProtectedInspectionActionRequest', 'ProtectedInspectionActionResponse'); }
|
|
73
|
+
submitAppeal(id, input) { return this.job(id, '/appeals', 'POST', input, 'ProtectedInspectionAppealRequest', 'ProtectedInspectionAppealResponse'); }
|
|
74
|
+
submitObservation(id, input) { return this.job(id, '/observations', 'POST', input, 'ProtectedInspectionObservationRequest', 'ProtectedInspectionObservationResponse'); }
|
|
75
|
+
start(id, input) { return this.job(id, '/start', 'POST', input, 'ProtectedInspectionStartRequest', 'ProtectedInspectionStartResponse'); }
|
|
76
|
+
job(id, suffix, method, input, inputSchema, outputSchema) {
|
|
77
|
+
if (!hash.test(id))
|
|
78
|
+
throw new TypeError('Protected job IDs are lowercase 32-byte hashes, not operation UUIDs.');
|
|
79
|
+
return this.call('/' + id + suffix, method, input, inputSchema, outputSchema, id);
|
|
80
|
+
}
|
|
81
|
+
invalid() { throw new ProtectedInspectionApiError(200, 'invalid_response', 'Protected-work response does not match the pinned request. Preserve the job and signed transaction; do not repeat funding.'); }
|
|
82
|
+
transaction(value, allowApproval = false, amount) {
|
|
83
|
+
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))
|
|
84
|
+
this.invalid();
|
|
85
|
+
if (allowApproval) {
|
|
86
|
+
if (value.to.toLowerCase() !== USDC || !amount || value.data.toLowerCase() !== '0x095ea7b3' + this.contract.slice(2).padStart(64, '0') + BigInt(amount).toString(16).padStart(64, '0'))
|
|
87
|
+
this.invalid();
|
|
88
|
+
}
|
|
89
|
+
else if (value.to.toLowerCase() !== this.contract)
|
|
90
|
+
this.invalid();
|
|
91
|
+
}
|
|
92
|
+
async call(suffix, method, input, inputSchema, outputSchema, id) {
|
|
93
|
+
if (suffix && (!this.contract || !this.verifierHash))
|
|
94
|
+
throw new TypeError('Pin contract and verifierHash before using protected-work methods.');
|
|
95
|
+
const body = input === undefined ? undefined : JSON.stringify(input);
|
|
96
|
+
if (body !== undefined && (new TextEncoder().encode(body).length > 65536 || inputSchema && !matches(inspectionSchemas[inputSchema], JSON.parse(body))))
|
|
97
|
+
throw new TypeError('Protected-work input does not match its schema or exceeds 64 KiB.');
|
|
98
|
+
if (record(input) && record(input.terms) && input.terms.verifierHash !== undefined && input.terms.verifierHash !== this.verifierHash)
|
|
99
|
+
throw new TypeError('Funding verifier differs from the pinned verifier.');
|
|
100
|
+
const response = await this.fetcher(this.origin + '/v1/protected-inspections' + suffix, { method, redirect: 'error', credentials: 'omit', headers: { Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, ...(body === undefined ? {} : { body }) });
|
|
101
|
+
let payload;
|
|
102
|
+
try {
|
|
103
|
+
if (!response.body)
|
|
104
|
+
throw new Error();
|
|
105
|
+
const reader = response.body.getReader(), chunks = [];
|
|
106
|
+
let size = 0;
|
|
107
|
+
try {
|
|
108
|
+
while (true) {
|
|
109
|
+
const item = await reader.read();
|
|
110
|
+
if (item.done)
|
|
111
|
+
break;
|
|
112
|
+
size += item.value.length;
|
|
113
|
+
if (size > 65536) {
|
|
114
|
+
await reader.cancel();
|
|
115
|
+
throw new Error();
|
|
116
|
+
}
|
|
117
|
+
chunks.push(item.value);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
reader.releaseLock();
|
|
122
|
+
}
|
|
123
|
+
const bytes = new Uint8Array(size);
|
|
124
|
+
let offset = 0;
|
|
125
|
+
for (const chunk of chunks) {
|
|
126
|
+
bytes.set(chunk, offset);
|
|
127
|
+
offset += chunk.length;
|
|
128
|
+
}
|
|
129
|
+
payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new ProtectedInspectionApiError(response.status, response.ok ? 'invalid_response' : 'http_error', 'Unreadable or oversized response; preserve recovery data.');
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok)
|
|
135
|
+
throw new ProtectedInspectionApiError(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.');
|
|
136
|
+
if (!matches(inspectionSchemas[outputSchema], payload) || !record(payload))
|
|
137
|
+
this.invalid();
|
|
138
|
+
if (id && 'jobId' in payload && payload.jobId !== id)
|
|
139
|
+
this.invalid();
|
|
140
|
+
if (record(payload.relay) && payload.relay.queued !== (payload.relay.id !== null))
|
|
141
|
+
this.invalid();
|
|
142
|
+
if (suffix === '') {
|
|
143
|
+
if (payload.protocol !== '402mob.protected-inspection.v1' || payload.network !== 'eip155:8453' || (typeof payload.asset !== 'string' || payload.asset.toLowerCase() !== USDC) || this.verifierHash && payload.verifierHash !== this.verifierHash)
|
|
144
|
+
this.invalid();
|
|
145
|
+
}
|
|
146
|
+
if (record(payload.terms) && payload.terms.verifierHash !== this.verifierHash)
|
|
147
|
+
this.invalid();
|
|
148
|
+
for (const key of ['transaction', 'submissionTransaction', 'evaluationTransaction', 'refundTransaction', 'finalizeTransaction'])
|
|
149
|
+
if (payload[key] !== undefined && payload[key] !== null)
|
|
150
|
+
this.transaction(payload[key]);
|
|
151
|
+
if (Array.isArray(payload.transactions)) {
|
|
152
|
+
const amount = record(input) && record(input.terms) ? String(input.terms.amount) : undefined;
|
|
153
|
+
payload.transactions.forEach((tx, i) => this.transaction(tx, i === 0, amount));
|
|
154
|
+
}
|
|
155
|
+
for (const key of ['signing', 'sellerSigning'])
|
|
156
|
+
if (payload[key] !== undefined && payload[key] !== null) {
|
|
157
|
+
const signing = payload[key];
|
|
158
|
+
if (!record(signing) || !record(signing.domain) || !record(signing.message))
|
|
159
|
+
this.invalid();
|
|
160
|
+
const d = signing.domain;
|
|
161
|
+
if (d.name !== '402Mob Protected Work' || d.version !== '3' || d.chainId !== 8453 || typeof d.verifyingContract !== 'string' || d.verifyingContract.toLowerCase() !== this.contract)
|
|
162
|
+
this.invalid();
|
|
163
|
+
if (id && signing.message.jobId !== id)
|
|
164
|
+
this.invalid();
|
|
165
|
+
if (suffix.endsWith('/candidate-signing') && record(input) && (signing.primaryType !== 'Candidate' || signing.message.attempt !== input.attempt || signing.message.resultHash !== input.resultHash))
|
|
166
|
+
this.invalid();
|
|
167
|
+
if (suffix.endsWith('/actions') && record(input) && signing.primaryType !== { receipt: 'Receipt', accept: 'Accept', challenge: 'Challenge' }[String(input.action)])
|
|
168
|
+
this.invalid();
|
|
169
|
+
}
|
|
170
|
+
if (suffix.endsWith('/actions') && record(input)) {
|
|
171
|
+
const draft = input.signature === undefined && ['receipt', 'accept', 'challenge'].includes(String(input.action));
|
|
172
|
+
if (draft ? (payload.signing === null || payload.transaction !== null) : (payload.signing !== null || payload.transaction === null))
|
|
173
|
+
this.invalid();
|
|
174
|
+
}
|
|
175
|
+
if (suffix === '/prepare') {
|
|
176
|
+
if (!record(input))
|
|
177
|
+
this.invalid();
|
|
178
|
+
const nodeCryptoModule = 'node:crypto';
|
|
179
|
+
const subtle = globalThis.crypto?.subtle ?? (await import(nodeCryptoModule)).webcrypto.subtle;
|
|
180
|
+
const bytes = await subtle.digest('SHA-256', new TextEncoder().encode(canonical(input.task)));
|
|
181
|
+
const expectedHash = '0x' + Array.from(new Uint8Array(bytes), x => x.toString(16).padStart(2, '0')).join('');
|
|
182
|
+
if (payload.workHash !== expectedHash)
|
|
183
|
+
this.invalid();
|
|
184
|
+
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))
|
|
185
|
+
this.invalid();
|
|
186
|
+
}
|
|
187
|
+
if (suffix.endsWith('/challenges')) {
|
|
188
|
+
if (!record(input) || typeof input.wallet !== 'string' || typeof payload.expiresAt !== 'number' || payload.wallet !== input.wallet || payload.action !== 'read')
|
|
189
|
+
this.invalid();
|
|
190
|
+
const now = Math.floor(Date.now() / 1000);
|
|
191
|
+
const expected = '402Mob protected inspection read proof\n' + canonical({ protocol: '402mob.protected-inspection.v1', origin: this.origin, contract: this.contract, chainId: 8453, jobId: id, action: 'read', wallet: input.wallet.toLowerCase(), expiresAt: payload.expiresAt });
|
|
192
|
+
if (payload.message !== expected || payload.expiresAt <= now || payload.expiresAt > now + 120)
|
|
193
|
+
this.invalid();
|
|
194
|
+
}
|
|
195
|
+
return payload;
|
|
196
|
+
}
|
|
197
|
+
}
|