@fitzdoud/valun 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +259 -2
- package/package.json +53 -4
- package/src/agent.js +590 -0
- package/src/basis.js +243 -0
- package/src/blake2b.js +137 -0
- package/src/bytes.js +60 -0
- package/src/client.js +565 -0
- package/src/dns.d.ts +4 -0
- package/src/dns.js +12 -0
- package/src/errors.js +18 -0
- package/src/file-store.d.ts +16 -0
- package/src/file-store.js +78 -0
- package/src/index.d.ts +650 -0
- package/src/index.js +24 -0
- package/src/measure.js +214 -0
- package/src/protocol.js +167 -0
- package/src/py.js +318 -0
- package/src/random.js +24 -0
- package/src/sandbox.js +131 -0
- package/src/schemas.js +157 -0
- package/src/sha256.js +52 -0
- package/src/tools.js +112 -0
package/src/measure.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Deterministic facts about a delivery, for the agent that reviews it (port of python/src/valun/measure.py).
|
|
2
|
+
//
|
|
3
|
+
// Counts words and links and checks them against the numeric requirements written in the agreed terms ("300-500
|
|
4
|
+
// words", "at least 3 cited sources"), and can check whether each link's host name exists in DNS. These are
|
|
5
|
+
// measurements, not judgements: whether the work is accurate, on topic and good is still the reviewing agent's call.
|
|
6
|
+
// DNS only: no page is fetched, no connection is opened to the linked hosts. This module looks nothing up by itself:
|
|
7
|
+
// host checks need a resolver (in Node: resolveHost from "@fitzdoud/valun/dns"); without one (a browser) the result says the hosts
|
|
8
|
+
// were not checked. The regular expressions follow Python's semantics (its whitespace, re.IGNORECASE letters, Unicode
|
|
9
|
+
// digits), so both implementations measure the same text the same way.
|
|
10
|
+
|
|
11
|
+
import {DIGIT, PyError, WS, ci, digitsInt, or, rstripChars, truthy, typeName} from './py.js';
|
|
12
|
+
|
|
13
|
+
const S = `[${WS}]`;
|
|
14
|
+
const URL_RE = new RegExp(`${ci('http')}${ci('s')}?://[^${WS}<>\\[\\]"'\`]+`, 'gu');
|
|
15
|
+
const HOST_RE = new RegExp(`^${ci('http')}${ci('s')}?://([^/:?#]+)`, 'u');
|
|
16
|
+
const WORD_RE = /[A-Za-z0-9][A-Za-z0-9'\u2019-]*/g;
|
|
17
|
+
const WORDS = `${ci('word')}${ci('s')}?`;
|
|
18
|
+
const WORD_RANGE = new RegExp(`(${DIGIT}{2,5})${S}*(?:-|\u2013|\u2014|${ci('to')})${S}*(${DIGIT}{2,5})[${WS}\\-]*${WORDS}`, 'u');
|
|
19
|
+
const MIN_WORDS = new RegExp(`(?:${ci('at least')}|${ci('minimum')}(?: ${ci('of')})?|${ci('min')}\\.?|${ci('no fewer than')})${S}*(${DIGIT}{2,5})${S}*${WORDS}`, 'u');
|
|
20
|
+
const MAX_WORDS = new RegExp(`(?:${ci('at most')}|${ci('up to')}|${ci('maximum')}(?: ${ci('of')})?|${ci('max')}\\.?|${ci('under')}|${ci('no more than')})${S}*(${DIGIT}{2,5})${S}*${WORDS}`, 'u');
|
|
21
|
+
const MIN_LINKS = new RegExp(`${ci('at least')}${S}*(${DIGIT}{1,2})${S}*(?:${ci('cited')}${S}+|${ci('linked')}${S}+)?(?:${ci('sources')}|${ci('links')}|${ci('references')}|${ci('citations')})`, 'u');
|
|
22
|
+
|
|
23
|
+
export const MAX_LINKS_CHECKED = 10;
|
|
24
|
+
export const HOW_MEASURED = 'by your plugin, deterministically: words = runs of letters/digits; links = distinct http(s) URLs. '
|
|
25
|
+
+ 'These are facts to use in your review, not a verdict on quality, accuracy or relevance.';
|
|
26
|
+
/** Shown instead of link_hosts when host checks are on but this runtime has no DNS resolver (JavaScript only). */
|
|
27
|
+
export const HOSTS_NOT_CHECKED = 'Link hosts were not looked up: this Valun client has no DNS resolver in this runtime (for '
|
|
28
|
+
+ 'example a browser), so it cannot tell whether the link hosts exist. Judge the sources yourself.';
|
|
29
|
+
const TIMED_OUT = 'unknown (lookup timed out)';
|
|
30
|
+
|
|
31
|
+
function text0(v) {
|
|
32
|
+
const t = or(v, '');
|
|
33
|
+
if (typeof t !== 'string') throw new PyError('TypeError', `expected string or bytes-like object, got '${typeName(t)}'`);
|
|
34
|
+
return t;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function wordCount(text) {
|
|
38
|
+
return (text0(text).match(WORD_RE) || []).length;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Distinct http(s) URLs in order of first appearance, without trailing punctuation or an unbalanced closing bracket. */
|
|
42
|
+
export function links(text) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const m of text0(text).matchAll(URL_RE)) {
|
|
45
|
+
let u = rstripChars(m[0], '.,;:!?*_');
|
|
46
|
+
// "(see https://x/y)." -> drop the closing bracket
|
|
47
|
+
while (u.endsWith(')') && u.split(')').length > u.split('(').length) u = rstripChars(u.slice(0, -1), '.,;:!?*_');
|
|
48
|
+
if (!out.includes(u)) out.push(u);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The lower-cased host name of an http(s) URL, or ''. */
|
|
54
|
+
export function hostOf(url) {
|
|
55
|
+
const m = HOST_RE.exec(url);
|
|
56
|
+
return m ? m[1].toLowerCase() : '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Numeric requirements found in the agreed service text (title, description, deliverable). */
|
|
60
|
+
export function requirements(termsText) {
|
|
61
|
+
const t = text0(termsText), req = {};
|
|
62
|
+
let m = WORD_RANGE.exec(t);
|
|
63
|
+
if (m) {
|
|
64
|
+
const [lo, hi] = [digitsInt(m[1]), digitsInt(m[2])].sort((a, b) => a - b);
|
|
65
|
+
req.min_words = lo;
|
|
66
|
+
req.max_words = hi;
|
|
67
|
+
} else {
|
|
68
|
+
if ((m = MIN_WORDS.exec(t))) req.min_words = digitsInt(m[1]);
|
|
69
|
+
if ((m = MAX_WORDS.exec(t))) req.max_words = digitsInt(m[1]);
|
|
70
|
+
}
|
|
71
|
+
if ((m = MIN_LINKS.exec(t))) req.min_links = digitsInt(m[1]);
|
|
72
|
+
return req;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------- DNS: what the addresses a host name resolves to say about it ----------
|
|
76
|
+
const V4 = (s, p) => ({net: parseV4(s), prefix: p, bits: 32});
|
|
77
|
+
const V6 = (s, p) => ({net: parseV6(s), prefix: p, bits: 128});
|
|
78
|
+
// CPython 3.14 ipaddress: not globally reachable blocks of the IANA special-purpose registries, and their exceptions.
|
|
79
|
+
const V4_PRIVATE = [['0.0.0.0', 8], ['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24],
|
|
80
|
+
['192.0.0.170', 31], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
|
81
|
+
['240.0.0.0', 4], ['255.255.255.255', 32]].map(([s, p]) => V4(s, p));
|
|
82
|
+
const V4_EXCEPTIONS = [V4('192.0.0.9', 32), V4('192.0.0.10', 32)];
|
|
83
|
+
const V4_SHARED = V4('100.64.0.0', 10); // neither private nor global
|
|
84
|
+
const V6_PRIVATE = [['::1', 128], ['::', 128], ['::ffff:0:0', 96], ['64:ff9b:1::', 48], ['100::', 64], ['2001::', 23], ['2001:db8::', 32],
|
|
85
|
+
['2002::', 16], ['3fff::', 20], ['fc00::', 7], ['fe80::', 10]].map(([s, p]) => V6(s, p));
|
|
86
|
+
const V6_EXCEPTIONS = [['2001:1::1', 128], ['2001:1::2', 128], ['2001:3::', 32], ['2001:4:112::', 48], ['2001:20::', 28],
|
|
87
|
+
['2001:30::', 28]].map(([s, p]) => V6(s, p));
|
|
88
|
+
|
|
89
|
+
function parseV4(s) {
|
|
90
|
+
const parts = s.split('.');
|
|
91
|
+
if (parts.length !== 4) return null;
|
|
92
|
+
let n = 0n;
|
|
93
|
+
for (const x of parts) {
|
|
94
|
+
if (!/^(?:0|[1-9][0-9]{0,2})$/.test(x) || Number(x) > 255) return null;
|
|
95
|
+
n = n * 256n + BigInt(x);
|
|
96
|
+
}
|
|
97
|
+
return n;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseV6(s) {
|
|
101
|
+
const lastColon = s.lastIndexOf(':');
|
|
102
|
+
if (lastColon < 0 || !/^[0-9a-fA-F:.]+$/.test(s)) return null;
|
|
103
|
+
let body = s, tail = [];
|
|
104
|
+
if (s.includes('.')) { // an IPv4 address in the last 32 bits
|
|
105
|
+
const v4 = parseV4(s.slice(lastColon + 1));
|
|
106
|
+
if (v4 === null) return null;
|
|
107
|
+
tail = [Number(v4 >> 16n), Number(v4 & 0xffffn)];
|
|
108
|
+
body = s.slice(0, lastColon + 1);
|
|
109
|
+
if (!body.endsWith('::')) body = body.slice(0, -1);
|
|
110
|
+
}
|
|
111
|
+
const halves = body.split('::');
|
|
112
|
+
if (halves.length > 2) return null;
|
|
113
|
+
const groups = h => (h === '' ? [] : h.split(':'));
|
|
114
|
+
const head = groups(halves[0]), back = halves.length === 2 ? groups(halves[1]) : [];
|
|
115
|
+
if (![...head, ...back].every(g => /^[0-9a-fA-F]{1,4}$/.test(g))) return null;
|
|
116
|
+
const n = head.length + back.length + tail.length;
|
|
117
|
+
if (halves.length === 2 ? n > 7 : n !== 8) return null; // "::" stands for at least one group
|
|
118
|
+
const words = [...head.map(g => parseInt(g, 16)), ...Array(8 - n).fill(0), ...back.map(g => parseInt(g, 16)), ...tail];
|
|
119
|
+
return words.reduce((x, w) => (x << 16n) | BigInt(w), 0n);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const inNet = (x, {net, prefix, bits}) => x >> BigInt(bits - prefix) === net >> BigInt(bits - prefix);
|
|
123
|
+
|
|
124
|
+
function v4Global(x) {
|
|
125
|
+
const priv = V4_PRIVATE.some(n => inNet(x, n)) && !V4_EXCEPTIONS.some(n => inNet(x, n));
|
|
126
|
+
return !inNet(x, V4_SHARED) && !priv;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Python's ipaddress.ip_address(a).is_global for an IPv4 or IPv6 address string. Throws TypeError if it is neither. */
|
|
130
|
+
export function isGlobalAddress(address) {
|
|
131
|
+
const s = String(address);
|
|
132
|
+
const v4 = parseV4(s);
|
|
133
|
+
if (v4 !== null) return v4Global(v4);
|
|
134
|
+
const v6 = s.includes(':') ? parseV6(s) : null;
|
|
135
|
+
if (v6 === null) throw new TypeError(`${JSON.stringify(s)} does not appear to be an IPv4 or IPv6 address`);
|
|
136
|
+
if (v6 >> 32n === 0xffffn) return v4Global(v6 & 0xffffffffn); // IPv4-mapped: the IPv4 address decides
|
|
137
|
+
return !(V6_PRIVATE.some(n => inNet(v6, n)) && !V6_EXCEPTIONS.some(n => inNet(v6, n)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 'public' if any address is globally reachable, else 'private' (only private, loopback or reserved addresses). */
|
|
141
|
+
export function classifyAddresses(addresses) {
|
|
142
|
+
return addresses.some(a => isGlobalAddress(String(a).split('%')[0])) ? 'public' : 'private';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A host checker from an address lookup: lookupAll(host) resolves to the host's address strings (or rejects when the
|
|
147
|
+
* name does not exist). The checker resolves to 'public' | 'private' | 'not_found', as the Python resolver does.
|
|
148
|
+
*/
|
|
149
|
+
export function hostResolver(lookupAll) {
|
|
150
|
+
if (typeof lookupAll !== 'function') throw new TypeError('hostResolver needs a lookup function');
|
|
151
|
+
return async host => {
|
|
152
|
+
let addresses;
|
|
153
|
+
try {
|
|
154
|
+
addresses = await lookupAll(host);
|
|
155
|
+
} catch {
|
|
156
|
+
return 'not_found';
|
|
157
|
+
}
|
|
158
|
+
return classifyAddresses([...new Set(addresses)]);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* {host: 'public' | 'private' | 'not_found' | 'unknown (lookup timed out)'} for the distinct hosts of `urls` (at most
|
|
164
|
+
* MAX_LINKS_CHECKED), looked up concurrently, waiting at most timeoutMs (default 4000) for all of them together.
|
|
165
|
+
*/
|
|
166
|
+
export async function hostStatus(urls, resolveHost, {timeoutMs = 4000} = {}) {
|
|
167
|
+
const hosts = [...new Set(urls.map(hostOf).filter(Boolean))].slice(0, MAX_LINKS_CHECKED);
|
|
168
|
+
const result = new Map(hosts.map(h => [h, TIMED_OUT])); // a Map, so a host called "__proto__" is kept like any other
|
|
169
|
+
if (!hosts.length) return {};
|
|
170
|
+
let open = true, timer, failure;
|
|
171
|
+
const work = Promise.all(hosts.map(h => Promise.resolve().then(() => resolveHost(h)).then(
|
|
172
|
+
s => { if (open) result.set(h, s); },
|
|
173
|
+
e => { if (open && failure === undefined) failure = {e}; })));
|
|
174
|
+
await Promise.race([work, new Promise(resolve => { timer = setTimeout(resolve, timeoutMs); })]);
|
|
175
|
+
open = false;
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (failure) throw failure.e;
|
|
178
|
+
return Object.fromEntries(result);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The measurements of a delivery against the agreed terms text (Python measure.measure). Resolves to
|
|
183
|
+
* {word_count, links, checks_against_agreed_terms, how_measured} plus, when checkHosts and there are links: link_hosts
|
|
184
|
+
* (and a warning for hosts that do not exist), or link_hosts_not_checked when no resolveHost is given.
|
|
185
|
+
*/
|
|
186
|
+
export async function measure(text, termsText, {checkHosts = true, resolveHost = null, timeoutMs = 4000} = {}) {
|
|
187
|
+
const words = wordCount(text), found = links(text);
|
|
188
|
+
const req = requirements(termsText), checks = [];
|
|
189
|
+
if ('min_words' in req || 'max_words' in req) {
|
|
190
|
+
const lo = req.min_words ?? null, hi = req.max_words ?? null;
|
|
191
|
+
const ok = (lo === null || words >= lo) && (hi === null || words <= hi);
|
|
192
|
+
const want = lo !== null && hi !== null ? `${lo}-${hi} words` : (lo !== null ? `at least ${lo} words` : `at most ${hi} words`);
|
|
193
|
+
checks.push({requirement: want, measured: `${words} words`, met: ok});
|
|
194
|
+
}
|
|
195
|
+
if ('min_links' in req) {
|
|
196
|
+
checks.push({requirement: `at least ${req.min_links} sources with links`, measured: `${found.length} distinct links`,
|
|
197
|
+
met: found.length >= req.min_links});
|
|
198
|
+
}
|
|
199
|
+
const out = {word_count: words, links: found, checks_against_agreed_terms: checks, how_measured: HOW_MEASURED};
|
|
200
|
+
if (truthy(checkHosts) && found.length) {
|
|
201
|
+
if (typeof resolveHost !== 'function') {
|
|
202
|
+
out.link_hosts_not_checked = HOSTS_NOT_CHECKED;
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
const st = await hostStatus(found, resolveHost, {timeoutMs});
|
|
206
|
+
out.link_hosts = st;
|
|
207
|
+
const missing = Object.entries(st).filter(([, s]) => s === 'not_found').map(([h]) => h);
|
|
208
|
+
if (missing.length) {
|
|
209
|
+
out.warning = 'These link hosts do not exist in DNS, so those sources cannot be real: ' + missing.join(', ')
|
|
210
|
+
+ '. A source whose link cannot work does not count as a working link.';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// valun-deal/1: protocol constants, canonical JSON, terms hash and the signed statement texts (spec sections 7 and 8).
|
|
2
|
+
|
|
3
|
+
import {bytesToHex, utf8} from './bytes.js';
|
|
4
|
+
import {ValunError} from './errors.js';
|
|
5
|
+
import {sha256} from './sha256.js';
|
|
6
|
+
|
|
7
|
+
export const NETWORK = 'valun-pilot-1';
|
|
8
|
+
export const PROTOCOL = 'valun-deal/1';
|
|
9
|
+
export const UNIT = 'PC';
|
|
10
|
+
export const CONSIDERATION = 'credit_note_on_delivery_accepted';
|
|
11
|
+
/** sha256hex("valun-pilot-credit/1"): the pilot's PC token id (it exists on no chain). */
|
|
12
|
+
export const PILOT_TOKEN_ID = bytesToHex(sha256(utf8('valun-pilot-credit/1')));
|
|
13
|
+
export const PILOT_DENOMINATION = `token:${PILOT_TOKEN_ID}`;
|
|
14
|
+
/** The pilot server the Python CLI uses by default. The Client never contacts it unless you pass it as `server`. */
|
|
15
|
+
export const DEFAULT_SERVER = 'https://valun-desk.fly.dev';
|
|
16
|
+
|
|
17
|
+
/** Lowercase hex SHA-256 of a string's UTF-8 bytes (or of a byte array). */
|
|
18
|
+
export function sha256Hex(x) {
|
|
19
|
+
if (typeof x === 'string') return bytesToHex(sha256(utf8(x)));
|
|
20
|
+
if (x instanceof Uint8Array) return bytesToHex(sha256(x));
|
|
21
|
+
throw new TypeError('sha256Hex takes a string or a Uint8Array');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPlainObject(v) {
|
|
25
|
+
if (v === null || typeof v !== 'object') return false;
|
|
26
|
+
const proto = Object.getPrototypeOf(v);
|
|
27
|
+
return proto === Object.prototype || proto === null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The server's canonical JSON (core.mjs canonical()): object keys sorted by UTF-16 code units, no whitespace, strings
|
|
32
|
+
* exactly as ECMAScript JSON.stringify writes them (non-ASCII kept as is, lone surrogates as \udxxx), numbers as
|
|
33
|
+
* ECMAScript prints them (-0 -> 0). Refuses values JSON cannot carry (undefined, functions, BigInt, NaN, Infinity,
|
|
34
|
+
* class instances) instead of guessing.
|
|
35
|
+
*/
|
|
36
|
+
export function canonical(v) {
|
|
37
|
+
if (v === null) return 'null';
|
|
38
|
+
if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
|
|
39
|
+
switch (typeof v) {
|
|
40
|
+
case 'string':
|
|
41
|
+
return JSON.stringify(v);
|
|
42
|
+
case 'boolean':
|
|
43
|
+
return v ? 'true' : 'false';
|
|
44
|
+
case 'number':
|
|
45
|
+
if (!Number.isFinite(v)) throw new ValunError('bad_terms', 'unsupported value in terms: a non-finite number');
|
|
46
|
+
return JSON.stringify(v);
|
|
47
|
+
case 'object':
|
|
48
|
+
if (!isPlainObject(v)) throw new ValunError('bad_terms', 'unsupported value in terms: a non-plain object');
|
|
49
|
+
return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
|
|
50
|
+
default:
|
|
51
|
+
throw new ValunError('bad_terms', `unsupported value in terms: ${typeof v}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** terms_hash = sha256hex(canonical(terms)). */
|
|
56
|
+
export function termsHash(terms) {
|
|
57
|
+
return sha256Hex(canonical(terms));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A statement text: "valun-pilot/<kind>/1" and the parts, joined by LF, no trailing newline. */
|
|
61
|
+
export function statement(kind, ...parts) {
|
|
62
|
+
return [`valun-pilot/${kind}/1`, ...parts].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The statement each act signs (spec 8.1). Reasons, content and enrollment codes are hashed here, exactly as given. */
|
|
66
|
+
export const statements = Object.freeze({
|
|
67
|
+
enroll: (participantId, pubkey, enrollmentCode, network = NETWORK) =>
|
|
68
|
+
statement('enroll', network, participantId, pubkey, sha256Hex(enrollmentCode)),
|
|
69
|
+
propose: termsHashHex => statement('propose', termsHashHex),
|
|
70
|
+
accept: termsHashHex => statement('accept', termsHashHex),
|
|
71
|
+
decline: (termsHashHex, reason) => statement('decline', termsHashHex, sha256Hex(reason)),
|
|
72
|
+
withdraw: termsHashHex => statement('withdraw', termsHashHex),
|
|
73
|
+
deliver: (termsHashHex, content) => statement('deliver', termsHashHex, sha256Hex(content)),
|
|
74
|
+
complete: (termsHashHex, deliverableSha256) => statement('complete', termsHashHex, deliverableSha256),
|
|
75
|
+
dispute: (termsHashHex, deliverableSha256, reason) => statement('dispute', termsHashHex, deliverableSha256, sha256Hex(reason)),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The server's text normalisation clean() without its length checks (spec 2.3): C0 controls except TAB/LF/CR and DEL
|
|
80
|
+
* become spaces, then ECMAScript trim(). The server stores and shows reasons in this form.
|
|
81
|
+
*/
|
|
82
|
+
export function cleanText(text) {
|
|
83
|
+
return String(text).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ' ').trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The characters the text rule starts from (spec 2.3, release v13): controls (Cc), format characters (Cf) and
|
|
88
|
+
* Default_Ignorable_Code_Point characters. A superset of what the server refuses: TAB/LF/CR, the five single marks and
|
|
89
|
+
* the selectors, keycaps and flags of the rule's contexts are allowed. To test text, use hasUnsafeText(); a check with
|
|
90
|
+
* this expression alone is stricter than the server.
|
|
91
|
+
*/
|
|
92
|
+
export const REASON_UNSAFE = /[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
|
|
93
|
+
const EMOJI = /\p{Emoji}/u;
|
|
94
|
+
// ZWNJ, ZWJ, LRM, RLM, ALM: allowed singly, never two in a row
|
|
95
|
+
const SINGLE_OK = new Set([0x200c, 0x200d, 0x200e, 0x200f, 0x061c]);
|
|
96
|
+
const FLAG_TAGS = new Set(['gbeng', 'gbsct', 'gbwls']); // the RGI subdivision flags of England, Scotland, Wales
|
|
97
|
+
const keycapBase = cp => (cp >= 0x30 && cp <= 0x39) || cp === 0x23 || cp === 0x2a;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Does the server (release v13, valun-app 6a6a34b, core.mjs hasUnsafeText) refuse this text? Spec 2.3, normative: per
|
|
101
|
+
* code point (a lone surrogate counts as one), TAB/LF/CR are allowed; ZWNJ, ZWJ, LRM, RLM and ALM are allowed unless the
|
|
102
|
+
* next code point is one of them too; U+FE0E/U+FE0F are allowed directly after an emoji other than 0-9, # and *, and
|
|
103
|
+
* U+FE0F in a keycap (0-9, # or *, U+FE0F, U+20E3); U+1F3F4 followed by the tag letters gbeng, gbsct or gbwls and
|
|
104
|
+
* U+E007F is allowed as a whole (the RGI flags of England, Scotland and Wales); any other Cc, Cf or
|
|
105
|
+
* Default_Ignorable_Code_Point character is refused. Unicode properties are those of the JavaScript engine (the server's
|
|
106
|
+
* is Node.js 24.21.0, Unicode 17.0). The same port of the server's function, step for step.
|
|
107
|
+
*/
|
|
108
|
+
export function hasUnsafeText(s) {
|
|
109
|
+
const cps = Array.from(String(s), c => c.codePointAt(0));
|
|
110
|
+
for (let i = 0; i < cps.length; i++) {
|
|
111
|
+
const cp = cps[i];
|
|
112
|
+
if (cp === 9 || cp === 10 || cp === 13) continue;
|
|
113
|
+
if (SINGLE_OK.has(cp)) {
|
|
114
|
+
if (SINGLE_OK.has(cps[i + 1])) return true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (cp === 0xfe0e || cp === 0xfe0f) {
|
|
118
|
+
const prev = cps[i - 1];
|
|
119
|
+
if (prev !== undefined && !keycapBase(prev) && EMOJI.test(String.fromCodePoint(prev))) continue;
|
|
120
|
+
if (prev !== undefined && keycapBase(prev) && cp === 0xfe0f && cps[i + 1] === 0x20e3) continue;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (cp === 0x1f3f4) {
|
|
124
|
+
let j = i + 1, tag = '';
|
|
125
|
+
while (j < cps.length && cps[j] >= 0xe0061 && cps[j] <= 0xe007a && tag.length < 6) tag += String.fromCharCode(cps[j++] - 0xe0000);
|
|
126
|
+
if (cps[j] === 0xe007f && FLAG_TAGS.has(tag)) i = j;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (REASON_UNSAFE.test(String.fromCodePoint(cp))) return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const REASON_LIMITS = {decline: {max: 500, raw: 600, required: false}, dispute: {max: 1000, raw: 1100, required: true}};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The refusal the server (release v13) gives for a decline or dispute reason before checking any signature, as
|
|
138
|
+
* {error: 'invalid_field', message}, or null when it accepts the reason. In the server's order: the type, the raw length
|
|
139
|
+
* (at most 600 / 1100 UTF-16 code units), the refused characters, then clean(): a dispute needs a reason, and the cleaned
|
|
140
|
+
* text may have at most 500 / 1000 code units.
|
|
141
|
+
*/
|
|
142
|
+
export function reasonProblem(kind, reason) {
|
|
143
|
+
const lim = REASON_LIMITS[kind];
|
|
144
|
+
if (!lim) throw new TypeError('kind must be "decline" or "dispute"');
|
|
145
|
+
const refuse = message => ({error: 'invalid_field', message});
|
|
146
|
+
if (reason === null || reason === undefined) reason = ''; // the server counts null/absent as ""
|
|
147
|
+
if (typeof reason !== 'string') return refuse('reason must be text');
|
|
148
|
+
if (reason.length > lim.raw) {
|
|
149
|
+
return refuse(`reason is longer than ${lim.raw} characters, counting leading and trailing whitespace (at most ${lim.max} without it)`);
|
|
150
|
+
}
|
|
151
|
+
if (hasUnsafeText(reason)) return refuse('reason contains control, bidi or invisible characters; remove them and sign again');
|
|
152
|
+
if (reason === '') return lim.required ? refuse('reason is required') : null;
|
|
153
|
+
const cleaned = cleanText(reason);
|
|
154
|
+
if (cleaned.length > lim.max) return refuse(`reason is longer than ${lim.max} characters`);
|
|
155
|
+
if (lim.required && cleaned === '') return refuse('reason is required');
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Text from the server or a counterparty, safe to show to a person or a model: every control, format (bidi, zero-width,
|
|
161
|
+
* BOM, soft hyphen, tag characters), line/paragraph separator and lone surrogate is replaced by a visible \u{XXXX}
|
|
162
|
+
* marker. TAB, LF and CR are kept. For display only: never sign or hash the result.
|
|
163
|
+
*/
|
|
164
|
+
export function neutralize(text) {
|
|
165
|
+
return String(text).replace(/(?![\t\n\r])[\p{Cc}\p{Cf}\p{Cs}\u2028\u2029]/gu,
|
|
166
|
+
ch => `\\u{${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}}`);
|
|
167
|
+
}
|