stated-protocol 5.0.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.
Files changed (62) hide show
  1. package/README.md +409 -0
  2. package/dist/constants.d.ts +225 -0
  3. package/dist/constants.d.ts.map +1 -0
  4. package/dist/constants.js +227 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/esm/constants.d.ts +225 -0
  7. package/dist/esm/constants.d.ts.map +1 -0
  8. package/dist/esm/hash.d.ts +37 -0
  9. package/dist/esm/hash.d.ts.map +1 -0
  10. package/dist/esm/index.d.ts +6 -0
  11. package/dist/esm/index.d.ts.map +1 -0
  12. package/dist/esm/index.js +2104 -0
  13. package/dist/esm/index.js.map +7 -0
  14. package/dist/esm/protocol.d.ts +30 -0
  15. package/dist/esm/protocol.d.ts.map +1 -0
  16. package/dist/esm/signature.d.ts +49 -0
  17. package/dist/esm/signature.d.ts.map +1 -0
  18. package/dist/esm/types.d.ts +115 -0
  19. package/dist/esm/types.d.ts.map +1 -0
  20. package/dist/esm/utils.d.ts +14 -0
  21. package/dist/esm/utils.d.ts.map +1 -0
  22. package/dist/hash.d.ts +37 -0
  23. package/dist/hash.d.ts.map +1 -0
  24. package/dist/hash.js +99 -0
  25. package/dist/hash.js.map +1 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +22 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/protocol.d.ts +30 -0
  31. package/dist/protocol.d.ts.map +1 -0
  32. package/dist/protocol.js +677 -0
  33. package/dist/protocol.js.map +1 -0
  34. package/dist/signature.d.ts +49 -0
  35. package/dist/signature.d.ts.map +1 -0
  36. package/dist/signature.js +169 -0
  37. package/dist/signature.js.map +1 -0
  38. package/dist/types.d.ts +115 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +30 -0
  41. package/dist/types.js.map +1 -0
  42. package/dist/utils.d.ts +14 -0
  43. package/dist/utils.d.ts.map +1 -0
  44. package/dist/utils.js +96 -0
  45. package/dist/utils.js.map +1 -0
  46. package/package.json +66 -0
  47. package/src/constants.ts +245 -0
  48. package/src/fixtures.test.ts +236 -0
  49. package/src/hash.test.ts +219 -0
  50. package/src/hash.ts +99 -0
  51. package/src/index.ts +5 -0
  52. package/src/organisation-verification.test.ts +50 -0
  53. package/src/person-verification.test.ts +55 -0
  54. package/src/poll.test.ts +28 -0
  55. package/src/protocol.ts +871 -0
  56. package/src/rating.test.ts +25 -0
  57. package/src/signature.test.ts +200 -0
  58. package/src/signature.ts +159 -0
  59. package/src/statement.test.ts +101 -0
  60. package/src/types.ts +185 -0
  61. package/src/utils.test.ts +140 -0
  62. package/src/utils.ts +104 -0
@@ -0,0 +1,871 @@
1
+ import { legalForms, UTCFormat, peopleCountBuckets } from './constants';
2
+ import { monthIndex, birthDateFormat } from './utils';
3
+ import { verifySignature } from './signature';
4
+ import { sha256 } from './hash';
5
+ import type {
6
+ Statement,
7
+ Poll,
8
+ OrganisationVerification,
9
+ PersonVerification,
10
+ Vote,
11
+ DisputeAuthenticity,
12
+ DisputeContent,
13
+ ResponseContent,
14
+ PDFSigning,
15
+ Rating,
16
+ RatingSubjectTypeValue,
17
+ } from './types';
18
+ import { isLegalForm, isPeopleCountBucket, isRatingValue } from './types';
19
+
20
+ const version = 5;
21
+
22
+ export * from './types';
23
+ export * from './constants';
24
+ export * from './utils';
25
+
26
+ export const buildStatement = ({
27
+ domain,
28
+ author,
29
+ time,
30
+ tags,
31
+ content,
32
+ representative,
33
+ supersededStatement,
34
+ translations,
35
+ attachments,
36
+ }: Statement) => {
37
+ if (content.match(/\nPublishing domain: /))
38
+ throw new Error(
39
+ "Statement must not contain 'Publishing domain: ', as this marks the beginning of a new statement."
40
+ );
41
+ if (content.match(/\n\n/))
42
+ throw new Error(
43
+ 'Statement content must not contain two line breaks in a row, as this is used for separating statements.'
44
+ );
45
+ if (content.match(/\nTranslation [a-z]{2,3}:\n/))
46
+ throw new Error(
47
+ "Statement content must not contain 'Translation XX:\\n' pattern, as this is reserved for translations."
48
+ );
49
+ if (content.match(/\nAttachments: /))
50
+ throw new Error(
51
+ "Statement content must not contain 'Attachments: ' on a new line, as this is reserved for file attachments."
52
+ );
53
+ if (typeof time !== 'object' || !time.toUTCString) throw new Error('Time must be a Date object.');
54
+ if (!domain) throw new Error('Publishing domain missing.');
55
+ if (attachments && attachments.length > 5) throw new Error('Maximum 5 attachments allowed.');
56
+ if (attachments) {
57
+ attachments.forEach((attachment, index) => {
58
+ if (!attachment.match(/^[A-Za-z0-9_-]+\.[a-zA-Z0-9]+$/)) {
59
+ throw new Error(
60
+ `Attachment ${index + 1} must be in format 'base64hash.extension' (URL-safe base64)`
61
+ );
62
+ }
63
+ });
64
+ }
65
+
66
+ if (translations) {
67
+ for (const [lang, translation] of Object.entries(translations)) {
68
+ if (translation.match(/\nPublishing domain: /))
69
+ throw new Error(`Translation for ${lang} must not contain 'Publishing domain: '.`);
70
+ if (translation.match(/Translation [a-z]{2,3}:\n/))
71
+ throw new Error(`Translation for ${lang} must not contain 'Translation XX:\\n' pattern.`);
72
+ }
73
+ }
74
+
75
+ const translationLines = translations
76
+ ? Object.entries(translations)
77
+ .map(([lang, translation]) => {
78
+ const translationWithNewline = translation + (translation.match(/\n$/) ? '' : '\n');
79
+ const indentedTranslation = translationWithNewline
80
+ .split('\n')
81
+ .map((line) => (line ? ' ' + line : line))
82
+ .join('\n');
83
+ return `Translation ${lang}:\n${indentedTranslation}`;
84
+ })
85
+ .join('')
86
+ : '';
87
+
88
+ const attachmentLines =
89
+ attachments && attachments.length > 0 ? 'Attachments: ' + attachments.join(', ') + '\n' : '';
90
+
91
+ const contentWithNewline = content + (content.match(/\n$/) ? '' : '\n');
92
+ // Only indent plain content (non-typed statements). Typed statements already have indentation from build functions.
93
+ const isTypedContent = contentWithNewline.trim().startsWith('Type:');
94
+ const finalContent = isTypedContent
95
+ ? contentWithNewline
96
+ : contentWithNewline
97
+ .split('\n')
98
+ .map((line) => (line ? ' ' + line : line))
99
+ .join('\n');
100
+
101
+ const statement =
102
+ 'Stated protocol version: ' +
103
+ version +
104
+ '\n' +
105
+ 'Publishing domain: ' +
106
+ domain +
107
+ '\n' +
108
+ 'Author: ' +
109
+ (author || '') +
110
+ '\n' +
111
+ (representative && representative?.length > 0
112
+ ? 'Authorized signing representative: ' + (representative || '') + '\n'
113
+ : '') +
114
+ 'Time: ' +
115
+ time.toUTCString() +
116
+ '\n' +
117
+ (tags && tags.length > 0 ? 'Tags: ' + tags.join(', ') + '\n' : '') +
118
+ (supersededStatement && supersededStatement?.length > 0
119
+ ? 'Superseded statement: ' + (supersededStatement || '') + '\n'
120
+ : '') +
121
+ 'Statement content:\n' +
122
+ finalContent +
123
+ translationLines +
124
+ attachmentLines;
125
+ if (statement.length > 3000)
126
+ throw new Error('Statement must not be longer than 3,000 characters.');
127
+ return statement;
128
+ };
129
+
130
+ export const parseStatement = ({
131
+ statement: input,
132
+ }: {
133
+ statement: string;
134
+ }): Statement & { type?: string; formatVersion: string } => {
135
+ if (input.length > 3000) throw new Error('Statement must not be longer than 3,000 characters.');
136
+ const beforeTranslations = input.split(/\nTranslation [a-z]{2,3}:\n/)[0];
137
+ if (beforeTranslations.match(/\n\n/))
138
+ throw new Error(
139
+ 'Statements cannot contain two line breaks in a row before translations, as this is used for separating statements.'
140
+ );
141
+
142
+ const signatureRegex = new RegExp(
143
+ '' +
144
+ /^(?<statement>[\s\S]+?)---\n/.source +
145
+ /Statement hash: (?<statementHash>[A-Za-z0-9_-]+)\n/.source +
146
+ /Public key: (?<publicKey>[A-Za-z0-9_-]+)\n/.source +
147
+ /Signature: (?<signature>[A-Za-z0-9_-]+)\n/.source +
148
+ /Algorithm: (?<algorithm>[^\n]+)\n/.source +
149
+ /$/.source
150
+ );
151
+ const signatureMatch = input.match(signatureRegex);
152
+
153
+ let statementToVerify = input;
154
+ let publicKey: string | undefined;
155
+ let signature: string | undefined;
156
+
157
+ if (signatureMatch && signatureMatch.groups) {
158
+ statementToVerify = signatureMatch.groups.statement;
159
+ const statementHash = signatureMatch.groups.statementHash;
160
+ publicKey = signatureMatch.groups.publicKey;
161
+ signature = signatureMatch.groups.signature;
162
+ const algorithm = signatureMatch.groups.algorithm;
163
+
164
+ if (algorithm !== 'Ed25519') {
165
+ throw new Error('Unsupported signature algorithm: ' + algorithm);
166
+ }
167
+
168
+ const computedHash = sha256(statementToVerify);
169
+ if (computedHash !== statementHash) {
170
+ throw new Error('Statement hash mismatch');
171
+ }
172
+
173
+ const isValid = verifySignature(statementToVerify, signature, publicKey);
174
+ if (!isValid) {
175
+ throw new Error('Invalid cryptographic signature');
176
+ }
177
+ }
178
+
179
+ const statementRegex = new RegExp(
180
+ '' +
181
+ /^Stated protocol version: (?<formatVersion>[^\n]+?)\n/.source +
182
+ /Publishing domain: (?<domain>[^\n]+?)\n/.source +
183
+ /Author: (?<author>[^\n]+?)\n/.source +
184
+ /(?:Authorized signing representative: (?<representative>[^\n]*?)\n)?/.source +
185
+ /Time: (?<time>[^\n]+?)\n/.source +
186
+ /(?:Tags: (?<tags>[^\n]*?)\n)?/.source +
187
+ /(?:Superseded statement: (?<supersededStatement>[^\n]*?)\n)?/.source +
188
+ /Statement content:\n/.source +
189
+ /(?:(?<typedContent> Type: (?<type>[^\n]+?)\n[\s\S]+?)(?=\nTranslation [a-z]{2,3}:\n|Attachments: |$)|(?<content>(?: [\s\S]+?)?))/
190
+ .source +
191
+ /(?=\nTranslation [a-z]{2,3}:\n|Attachments: |$)/.source +
192
+ /(?<translations>(?:\nTranslation [a-z]{2,3}:\n[\s\S]+?)*)/.source +
193
+ /(?:Attachments: (?<attachments>[^\n]+?)\n)?/.source +
194
+ /$/.source
195
+ );
196
+ const match = statementToVerify.match(statementRegex);
197
+ if (!match || !match.groups) throw new Error('Invalid statement format: ' + input);
198
+
199
+ const {
200
+ domain,
201
+ author,
202
+ representative,
203
+ time: timeStr,
204
+ tags: tagsStr,
205
+ supersededStatement,
206
+ attachments: attachmentsStr,
207
+ formatVersion,
208
+ content,
209
+ typedContent,
210
+ type,
211
+ translations: translationsStr,
212
+ } = match.groups;
213
+
214
+ const parsed = {
215
+ domain,
216
+ author,
217
+ representative,
218
+ timeStr,
219
+ tagsStr,
220
+ supersededStatement,
221
+ formatVersion,
222
+ content: content
223
+ ? content
224
+ .split('\n')
225
+ .map((line) => (line.startsWith(' ') ? line.substring(4) : line))
226
+ .join('\n')
227
+ .replace(/\n$/, '')
228
+ : typedContent,
229
+ type: type ? type.toLowerCase().replace(' ', '_') : undefined,
230
+ translationsStr,
231
+ };
232
+ if (!parsed.timeStr.match(UTCFormat))
233
+ throw new Error('Invalid statement format: time must be in UTC');
234
+ if (!parsed.domain) throw new Error('Invalid statement format: domain is required');
235
+ if (!parsed.author) throw new Error('Invalid statement format: author is required');
236
+ if (!parsed.content) throw new Error('Invalid statement format: statement content is required');
237
+ if (!parsed.formatVersion)
238
+ throw new Error('Invalid statement format: format version is required');
239
+ if (parsed.formatVersion !== '5')
240
+ throw new Error(
241
+ `Invalid statement format: only version 5 is supported, got version ${parsed.formatVersion}`
242
+ );
243
+
244
+ const tags = parsed.tagsStr?.split(', ');
245
+ const time = new Date(parsed.timeStr);
246
+
247
+ let attachments: string[] | undefined = undefined;
248
+ if (attachmentsStr && attachmentsStr.length > 0) {
249
+ attachments = attachmentsStr.split(', ').map((a) => a.trim());
250
+ if (attachments.length > 5) {
251
+ throw new Error('Maximum 5 attachments allowed');
252
+ }
253
+ }
254
+
255
+ let translations: Record<string, string> | undefined = undefined;
256
+ if (parsed.translationsStr && parsed.translationsStr.length > 0) {
257
+ translations = {};
258
+ const translationParts = parsed.translationsStr
259
+ .split(/\nTranslation ([a-z]{2,3}):\n/)
260
+ .filter((part) => part.length > 0);
261
+ for (let i = 0; i < translationParts.length; i += 2) {
262
+ if (i + 1 < translationParts.length) {
263
+ const lang = translationParts[i];
264
+ const rawTranslation = translationParts[i + 1];
265
+ // Strip indentation from translation content
266
+ const translation = rawTranslation
267
+ .split('\n')
268
+ .map((line) => (line.startsWith(' ') ? line.substring(4) : line))
269
+ .join('\n')
270
+ .replace(/\n$/, '');
271
+ translations[lang] = translation;
272
+ }
273
+ }
274
+ }
275
+
276
+ return {
277
+ domain: parsed.domain,
278
+ author: parsed.author,
279
+ representative: parsed.representative,
280
+ time,
281
+ tags: tags && tags.length > 0 ? tags : undefined,
282
+ supersededStatement: parsed.supersededStatement,
283
+ formatVersion: parsed.formatVersion,
284
+ content: parsed.content,
285
+ type: parsed.type?.toLowerCase().replace(' ', '_'),
286
+ translations: translations && Object.keys(translations).length > 0 ? translations : undefined,
287
+ attachments: attachments && attachments.length > 0 ? attachments : undefined,
288
+ };
289
+ };
290
+
291
+ export const buildPollContent = ({
292
+ deadline,
293
+ poll,
294
+ scopeDescription,
295
+ options,
296
+ allowArbitraryVote,
297
+ }: Poll) => {
298
+ if (!poll) throw new Error('Poll must contain a poll question.');
299
+ if (poll.includes('\n')) throw new Error('Poll question must be single line.');
300
+ if (scopeDescription && scopeDescription.includes('\n'))
301
+ throw new Error('Scope description must be single line.');
302
+ options.forEach((option, index) => {
303
+ if (option && option.includes('\n'))
304
+ throw new Error(`Option ${index + 1} must be single line.`);
305
+ });
306
+ const content =
307
+ ' Type: Poll\n' +
308
+ (deadline ? ' Voting deadline: ' + deadline.toUTCString() + '\n' : '') +
309
+ ' Poll: ' +
310
+ poll +
311
+ '\n' +
312
+ (options.length > 0 && options[0] ? ' Option 1: ' + options[0] + '\n' : '') +
313
+ (options.length > 1 && options[1] ? ' Option 2: ' + options[1] + '\n' : '') +
314
+ (options.length > 2 && options[2] ? ' Option 3: ' + options[2] + '\n' : '') +
315
+ (options.length > 3 && options[3] ? ' Option 4: ' + options[3] + '\n' : '') +
316
+ (options.length > 4 && options[4] ? ' Option 5: ' + options[4] + '\n' : '') +
317
+ (allowArbitraryVote === true || allowArbitraryVote === false
318
+ ? ' Allow free text votes: ' + (allowArbitraryVote ? 'Yes' : 'No') + '\n'
319
+ : '') +
320
+ (scopeDescription ? ' Who can vote: ' + scopeDescription + '\n' : '');
321
+ return content;
322
+ };
323
+
324
+ export const parsePoll = (content: string, version?: string): Poll => {
325
+ if (version !== '5') throw new Error('Invalid version ' + version);
326
+ const pollRegex = new RegExp(
327
+ '' +
328
+ /^ Type: Poll\n/.source +
329
+ /(?: Voting deadline: (?<deadline>[^\n]+?)\n)?/.source +
330
+ / Poll: (?<poll>[^\n]+?)\n/.source +
331
+ /(?: Option 1: (?<option1>[^\n]+?)\n)?/.source +
332
+ /(?: Option 2: (?<option2>[^\n]+?)\n)?/.source +
333
+ /(?: Option 3: (?<option3>[^\n]+?)\n)?/.source +
334
+ /(?: Option 4: (?<option4>[^\n]+?)\n)?/.source +
335
+ /(?: Option 5: (?<option5>[^\n]+?)\n)?/.source +
336
+ /(?: Allow free text votes: (?<allowArbitraryVote>Yes|No)\n)?/.source +
337
+ /(?: Who can vote: (?<scopeDescription>[^\n]+?)\n)?/.source +
338
+ /$/.source
339
+ );
340
+ const match = content.match(pollRegex);
341
+ if (!match || !match.groups) throw new Error('Invalid poll format: ' + content);
342
+
343
+ const {
344
+ deadline,
345
+ poll,
346
+ option1,
347
+ option2,
348
+ option3,
349
+ option4,
350
+ option5,
351
+ allowArbitraryVote: allowArbitraryVoteStr,
352
+ scopeDescription,
353
+ } = match.groups;
354
+
355
+ const options = [option1, option2, option3, option4, option5].filter((o) => o);
356
+ const allowArbitraryVote =
357
+ allowArbitraryVoteStr === 'Yes' ? true : allowArbitraryVoteStr === 'No' ? false : undefined;
358
+ const deadlineStr = deadline;
359
+ if (deadlineStr && !deadlineStr.match(UTCFormat))
360
+ throw new Error('Invalid poll, deadline must be in UTC: ' + deadlineStr);
361
+ return {
362
+ deadline: deadlineStr ? new Date(deadlineStr) : undefined,
363
+ poll,
364
+ options,
365
+ allowArbitraryVote,
366
+ scopeDescription,
367
+ };
368
+ };
369
+
370
+ export const buildOrganisationVerificationContent = ({
371
+ name,
372
+ englishName,
373
+ country,
374
+ city,
375
+ province,
376
+ legalForm,
377
+ department,
378
+ domain,
379
+ foreignDomain,
380
+ serialNumber,
381
+ confidence,
382
+ reliabilityPolicy,
383
+ employeeCount,
384
+ pictureHash,
385
+ latitude,
386
+ longitude,
387
+ population,
388
+ publicKey,
389
+ }: OrganisationVerification) => {
390
+ if (!name || !country || !legalForm || (!domain && !foreignDomain))
391
+ throw new Error('Missing required fields');
392
+ if (!Object.values(legalForms).includes(legalForm))
393
+ throw new Error('Invalid legal form ' + legalForm);
394
+ if (employeeCount && !Object.values(peopleCountBuckets).includes(employeeCount))
395
+ throw new Error('Invalid employee count ' + employeeCount);
396
+ if (population && !Object.values(peopleCountBuckets).includes(population))
397
+ throw new Error('Invalid population ' + population);
398
+ if (confidence && !('' + confidence)?.match(/^[0-9.]+$/))
399
+ throw new Error('Invalid confidence ' + confidence);
400
+ if (pictureHash && !pictureHash.match(/^[A-Za-z0-9_-]+\.[a-zA-Z0-9]+$/)) {
401
+ throw new Error("Logo must be in format 'base64hash.extension' (URL-safe base64)");
402
+ }
403
+ if (publicKey && !publicKey.match(/^[A-Za-z0-9_-]+$/)) {
404
+ throw new Error('Public key must be in URL-safe base64 format (A-Z, a-z, 0-9, _, -)');
405
+ }
406
+
407
+ return (
408
+ ' Type: Organisation verification\n' +
409
+ ' Description: We verified the following information about an organisation.\n' +
410
+ ' Name: ' +
411
+ name +
412
+ '\n' +
413
+ (englishName ? ' English name: ' + englishName + '\n' : '') +
414
+ ' Country: ' +
415
+ country +
416
+ '\n' +
417
+ ' Legal form: ' +
418
+ legalForm +
419
+ '\n' +
420
+ (domain ? ' Owner of the domain: ' + domain + '\n' : '') +
421
+ (foreignDomain
422
+ ? ' Foreign domain used for publishing statements: ' + foreignDomain + '\n'
423
+ : '') +
424
+ (department ? ' Department using the domain: ' + department + '\n' : '') +
425
+ (province ? ' Province or state: ' + province + '\n' : '') +
426
+ (serialNumber ? ' Business register number: ' + serialNumber + '\n' : '') +
427
+ (city ? ' City: ' + city + '\n' : '') +
428
+ (latitude ? ' Latitude: ' + latitude + '\n' : '') +
429
+ (longitude ? ' Longitude: ' + longitude + '\n' : '') +
430
+ (population ? ' Population: ' + population + '\n' : '') +
431
+ (pictureHash ? ' Logo: ' + pictureHash + '\n' : '') +
432
+ (employeeCount ? ' Employee count: ' + employeeCount + '\n' : '') +
433
+ (publicKey ? ' Public key: ' + publicKey + '\n' : '') +
434
+ (reliabilityPolicy ? ' Reliability policy: ' + reliabilityPolicy + '\n' : '') +
435
+ (confidence ? ' Confidence: ' + confidence + '\n' : '')
436
+ );
437
+ };
438
+
439
+ export const parseOrganisationVerification = (content: string): OrganisationVerification => {
440
+ const organisationVerificationRegex = new RegExp(
441
+ '' +
442
+ /^ Type: Organisation verification\n/.source +
443
+ / Description: We verified the following information about an organisation.\n/.source +
444
+ / Name: (?<name>[^\n]+?)\n/.source +
445
+ /(?: English name: (?<englishName>[^\n]+?)\n)?/.source +
446
+ / Country: (?<country>[^\n]+?)\n/.source +
447
+ / Legal (?:form|entity): (?<legalForm>[^\n]+?)\n/.source +
448
+ /(?: Owner of the domain: (?<domain>[^\n]+?)\n)?/.source +
449
+ /(?: Foreign domain used for publishing statements: (?<foreignDomain>[^\n]+?)\n)?/.source +
450
+ /(?: Department using the domain: (?<department>[^\n]+?)\n)?/.source +
451
+ /(?: Province or state: (?<province>[^\n]+?)\n)?/.source +
452
+ /(?: Business register number: (?<serialNumber>[^\n]+?)\n)?/.source +
453
+ /(?: City: (?<city>[^\n]+?)\n)?/.source +
454
+ /(?: Latitude: (?<latitude>[^\n]+?)\n)?/.source +
455
+ /(?: Longitude: (?<longitude>[^\n]+?)\n)?/.source +
456
+ /(?: Population: (?<population>[^\n]+?)\n)?/.source +
457
+ /(?: Logo: (?<pictureHash>[A-Za-z0-9_-]+\.[a-zA-Z0-9]+)\n)?/.source +
458
+ /(?: Employee count: (?<employeeCount>[01,+-]+?)\n)?/.source +
459
+ /(?: Public key: (?<publicKey>[A-Za-z0-9_-]+)\n)?/.source +
460
+ /(?: Reliability policy: (?<reliabilityPolicy>[^\n]+?)\n)?/.source +
461
+ /(?: Confidence: (?<confidence>[0-9.]+?))?/.source +
462
+ /\n?$/.source
463
+ );
464
+ const match = content.match(organisationVerificationRegex);
465
+ if (!match || !match.groups)
466
+ throw new Error('Invalid organisation verification format: ' + content);
467
+
468
+ const {
469
+ name,
470
+ englishName,
471
+ country,
472
+ legalForm,
473
+ domain,
474
+ foreignDomain,
475
+ department,
476
+ province,
477
+ serialNumber,
478
+ city,
479
+ latitude,
480
+ longitude,
481
+ population,
482
+ pictureHash,
483
+ employeeCount,
484
+ publicKey,
485
+ reliabilityPolicy,
486
+ confidence,
487
+ } = match.groups;
488
+
489
+ if (!isLegalForm(legalForm)) {
490
+ throw new Error('Invalid legal form after validation: ' + legalForm);
491
+ }
492
+
493
+ return {
494
+ name,
495
+ englishName,
496
+ country,
497
+ legalForm,
498
+ domain,
499
+ foreignDomain,
500
+ department,
501
+ province,
502
+ serialNumber,
503
+ city,
504
+ latitude: latitude ? parseFloat(latitude) : undefined,
505
+ longitude: longitude ? parseFloat(longitude) : undefined,
506
+ population: population && isPeopleCountBucket(population) ? population : undefined,
507
+ pictureHash,
508
+ employeeCount: employeeCount && isPeopleCountBucket(employeeCount) ? employeeCount : undefined,
509
+ publicKey,
510
+ reliabilityPolicy,
511
+ confidence: confidence ? parseFloat(confidence) : undefined,
512
+ };
513
+ };
514
+
515
+ export const buildPersonVerificationContent = ({
516
+ name,
517
+ countryOfBirth,
518
+ cityOfBirth,
519
+ ownDomain,
520
+ foreignDomain,
521
+ dateOfBirth,
522
+ jobTitle,
523
+ employer,
524
+ verificationMethod,
525
+ confidence,
526
+ picture,
527
+ reliabilityPolicy,
528
+ publicKey,
529
+ }: PersonVerification) => {
530
+ if (!name || !countryOfBirth || !cityOfBirth || !dateOfBirth || (!ownDomain && !foreignDomain)) {
531
+ throw new Error('Missing required fields for person verification');
532
+ }
533
+ if (publicKey && !publicKey.match(/^[A-Za-z0-9_-]+$/)) {
534
+ throw new Error('Public key must be in URL-safe base64 format (A-Z, a-z, 0-9, _, -)');
535
+ }
536
+ const [day, month, year] = dateOfBirth
537
+ .toUTCString()
538
+ .split(' ')
539
+ .filter((_i, j) => [1, 2, 3].includes(j));
540
+ const content =
541
+ ' Type: Person verification\n' +
542
+ ' Description: We verified the following information about a person.\n' +
543
+ ' Name: ' +
544
+ name +
545
+ '\n' +
546
+ ' Date of birth: ' +
547
+ [day.replace(/^0/, ''), month, year].join(' ') +
548
+ '\n' +
549
+ ' City of birth: ' +
550
+ cityOfBirth +
551
+ '\n' +
552
+ ' Country of birth: ' +
553
+ countryOfBirth +
554
+ '\n' +
555
+ (jobTitle ? ' Job title: ' + jobTitle + '\n' : '') +
556
+ (employer ? ' Employer: ' + employer + '\n' : '') +
557
+ (ownDomain ? ' Owner of the domain: ' + ownDomain + '\n' : '') +
558
+ (foreignDomain
559
+ ? ' Foreign domain used for publishing statements: ' + foreignDomain + '\n'
560
+ : '') +
561
+ (picture ? ' Picture: ' + picture + '\n' : '') +
562
+ (verificationMethod ? ' Verification method: ' + verificationMethod + '\n' : '') +
563
+ (publicKey ? ' Public key: ' + publicKey + '\n' : '') +
564
+ (confidence ? ' Confidence: ' + confidence + '\n' : '') +
565
+ (reliabilityPolicy ? ' Reliability policy: ' + reliabilityPolicy + '\n' : '');
566
+ return content;
567
+ };
568
+
569
+ export const parsePersonVerification = (content: string): PersonVerification => {
570
+ const domainVerificationRegex = new RegExp(
571
+ '' +
572
+ /^ Type: Person verification\n/.source +
573
+ / Description: We verified the following information about a person.\n/.source +
574
+ / Name: (?<name>[^\n]+?)\n/.source +
575
+ / Date of birth: (?<dateOfBirth>[^\n]+?)\n/.source +
576
+ / City of birth: (?<cityOfBirth>[^\n]+?)\n/.source +
577
+ / Country of birth: (?<countryOfBirth>[^\n]+?)\n/.source +
578
+ /(?: Job title: (?<jobTitle>[^\n]+?)\n)?/.source +
579
+ /(?: Employer: (?<employer>[^\n]+?)\n)?/.source +
580
+ /(?: Owner of the domain: (?<domain>[^\n]+?)\n)?/.source +
581
+ /(?: Foreign domain used for publishing statements: (?<foreignDomain>[^\n]+?)\n)?/.source +
582
+ /(?: Picture: (?<picture>[^\n]+?)\n)?/.source +
583
+ /(?: Verification method: (?<verificationMethod>[^\n]+?)\n)?/.source +
584
+ /(?: Public key: (?<publicKey>[A-Za-z0-9_-]+)\n)?/.source +
585
+ /(?: Confidence: (?<confidence>[^\n]+?)\n)?/.source +
586
+ /(?: Reliability policy: (?<reliabilityPolicy>[^\n]+?)\n)?/.source +
587
+ /$/.source
588
+ );
589
+ const match = content.match(domainVerificationRegex);
590
+ if (!match || !match.groups) throw new Error('Invalid person verification format: ' + content);
591
+
592
+ const {
593
+ name,
594
+ dateOfBirth: dateOfBirthStr,
595
+ cityOfBirth,
596
+ countryOfBirth,
597
+ jobTitle,
598
+ employer,
599
+ domain,
600
+ foreignDomain,
601
+ picture,
602
+ verificationMethod,
603
+ publicKey,
604
+ confidence,
605
+ reliabilityPolicy,
606
+ } = match.groups;
607
+
608
+ if (dateOfBirthStr && !dateOfBirthStr.match(birthDateFormat))
609
+ throw new Error('Invalid birth date format: ' + dateOfBirthStr);
610
+ const { d, month, y } = dateOfBirthStr.match(birthDateFormat)?.groups || {};
611
+ if (!d || !month || !y) throw new Error('Invalid birth date format: ' + dateOfBirthStr);
612
+
613
+ return {
614
+ name,
615
+ dateOfBirth: new Date(Date.UTC(parseInt(y), monthIndex(month), parseInt(d))),
616
+ cityOfBirth,
617
+ countryOfBirth,
618
+ jobTitle,
619
+ employer,
620
+ ownDomain: domain,
621
+ foreignDomain,
622
+ picture,
623
+ verificationMethod,
624
+ publicKey,
625
+ confidence: confidence ? parseFloat(confidence) : undefined,
626
+ reliabilityPolicy,
627
+ };
628
+ };
629
+
630
+ export const buildVoteContent = ({ pollHash, poll, vote }: Vote) => {
631
+ const content =
632
+ ' Type: Vote\n' +
633
+ ' Poll id: ' +
634
+ pollHash +
635
+ '\n' +
636
+ ' Poll:\n ' +
637
+ poll +
638
+ '\n' +
639
+ ' Option:\n ' +
640
+ vote +
641
+ '\n';
642
+ return content;
643
+ };
644
+
645
+ export const parseVote = (content: string): Vote => {
646
+ const voteRegex = new RegExp(
647
+ '' +
648
+ /^ Type: Vote\n/.source +
649
+ / Poll id: (?<pollHash>[^\n]+?)\n/.source +
650
+ / Poll:\n (?<poll>[^\n]+?)\n/.source +
651
+ / Option:\n (?<vote>[^\n]+?)\n/.source +
652
+ /$/.source
653
+ );
654
+ const match = content.match(voteRegex);
655
+ if (!match || !match.groups) throw new Error('Invalid vote format: ' + content);
656
+
657
+ const { pollHash, poll, vote } = match.groups;
658
+ return { pollHash, poll, vote };
659
+ };
660
+
661
+ export const buildDisputeAuthenticityContent = ({
662
+ hash,
663
+ confidence,
664
+ reliabilityPolicy,
665
+ }: DisputeAuthenticity) => {
666
+ const content =
667
+ ' Type: Dispute statement authenticity\n' +
668
+ ' Description: We think that the referenced statement is not authentic.\n' +
669
+ ' Hash of referenced statement: ' +
670
+ hash +
671
+ '\n' +
672
+ (confidence ? ' Confidence: ' + confidence + '\n' : '') +
673
+ (reliabilityPolicy ? ' Reliability policy: ' + reliabilityPolicy + '\n' : '');
674
+ return content;
675
+ };
676
+
677
+ export const parseDisputeAuthenticity = (content: string): DisputeAuthenticity => {
678
+ const disputeRegex = new RegExp(
679
+ '' +
680
+ /^ Type: Dispute statement authenticity\n/.source +
681
+ / Description: We think that the referenced statement is not authentic.\n/.source +
682
+ / Hash of referenced statement: (?<hash>[^\n]+?)\n/.source +
683
+ /(?: Confidence: (?<confidence>[^\n]*?)\n)?/.source +
684
+ /(?: Reliability policy: (?<reliabilityPolicy>[^\n]+?)\n)?/.source +
685
+ /$/.source
686
+ );
687
+ const match = content.match(disputeRegex);
688
+ if (!match || !match.groups) throw new Error('Invalid dispute authenticity format: ' + content);
689
+
690
+ const { hash, confidence, reliabilityPolicy } = match.groups;
691
+ return {
692
+ hash,
693
+ confidence: confidence ? parseFloat(confidence) : undefined,
694
+ reliabilityPolicy,
695
+ };
696
+ };
697
+
698
+ export const buildDisputeContentContent = ({
699
+ hash,
700
+ confidence,
701
+ reliabilityPolicy,
702
+ }: DisputeContent) => {
703
+ const content =
704
+ ' Type: Dispute statement content\n' +
705
+ ' Description: We think that the content of the referenced statement is false.\n' +
706
+ ' Hash of referenced statement: ' +
707
+ hash +
708
+ '\n' +
709
+ (confidence ? ' Confidence: ' + confidence + '\n' : '') +
710
+ (reliabilityPolicy ? ' Reliability policy: ' + reliabilityPolicy + '\n' : '');
711
+ return content;
712
+ };
713
+
714
+ export const parseDisputeContent = (content: string): DisputeContent => {
715
+ const disputeRegex = new RegExp(
716
+ '' +
717
+ /^ Type: Dispute statement content\n/.source +
718
+ / Description: We think that the content of the referenced statement is false.\n/.source +
719
+ / Hash of referenced statement: (?<hash>[^\n]+?)\n/.source +
720
+ /(?: Confidence: (?<confidence>[^\n]*?)\n)?/.source +
721
+ /(?: Reliability policy: (?<reliabilityPolicy>[^\n]+?)\n)?/.source +
722
+ /$/.source
723
+ );
724
+ const match = content.match(disputeRegex);
725
+ if (!match || !match.groups) throw new Error('Invalid dispute content format: ' + content);
726
+
727
+ const { hash, confidence, reliabilityPolicy } = match.groups;
728
+ return {
729
+ hash,
730
+ confidence: confidence ? parseFloat(confidence) : undefined,
731
+ reliabilityPolicy,
732
+ };
733
+ };
734
+
735
+ export const buildResponseContent = ({ hash, response }: ResponseContent) => {
736
+ const content =
737
+ ' Type: Response\n' +
738
+ ' Hash of referenced statement: ' +
739
+ hash +
740
+ '\n' +
741
+ ' Response:\n ' +
742
+ response +
743
+ '\n';
744
+ return content;
745
+ };
746
+
747
+ export const parseResponseContent = (content: string): ResponseContent => {
748
+ const responseRegex = new RegExp(
749
+ '' +
750
+ /^ Type: Response\n/.source +
751
+ / Hash of referenced statement: (?<hash>[^\n]+?)\n/.source +
752
+ / Response:\n (?<response>[^\n]*?)\n/.source +
753
+ /$/.source
754
+ );
755
+ const match = content.match(responseRegex);
756
+ if (!match || !match.groups) throw new Error('Invalid response content format: ' + content);
757
+
758
+ const { hash, response } = match.groups;
759
+ return { hash, response };
760
+ };
761
+
762
+ export const buildPDFSigningContent = ({ hash }: PDFSigning) => {
763
+ if (!hash.match(/^[A-Za-z0-9_-]+$/)) {
764
+ throw new Error('PDF file hash must be in URL-safe base64 format (A-Z, a-z, 0-9, _, -)');
765
+ }
766
+ const content =
767
+ ' Type: Sign PDF\n' +
768
+ ' Description: We hereby digitally sign the referenced PDF file.\n' +
769
+ ' PDF file hash: ' +
770
+ hash +
771
+ '\n';
772
+ return content;
773
+ };
774
+
775
+ export const parsePDFSigning = (content: string): PDFSigning => {
776
+ const signingRegex = new RegExp(
777
+ '' +
778
+ /^ Type: Sign PDF\n/.source +
779
+ / Description: We hereby digitally sign the referenced PDF file.\n/.source +
780
+ / PDF file hash: (?<hash>[A-Za-z0-9_-]+)\n/.source +
781
+ /$/.source
782
+ );
783
+ const match = content.match(signingRegex);
784
+ if (!match || !match.groups) throw new Error('Invalid PDF signing format: ' + content);
785
+
786
+ const { hash } = match.groups;
787
+ return { hash };
788
+ };
789
+
790
+ export const buildRating = ({
791
+ subjectName,
792
+ subjectType,
793
+ subjectReference,
794
+ documentFileHash,
795
+ rating,
796
+ quality,
797
+ comment,
798
+ }: Rating) => {
799
+ if (![1, 2, 3, 4, 5].includes(rating)) throw new Error('Invalid rating: ' + rating);
800
+ const content =
801
+ ' Type: Rating\n' +
802
+ (subjectType ? ' Subject type: ' + subjectType + '\n' : '') +
803
+ ' Subject name: ' +
804
+ subjectName +
805
+ '\n' +
806
+ (subjectReference ? ' URL that identifies the subject: ' + subjectReference + '\n' : '') +
807
+ (documentFileHash ? ' Document file hash: ' + documentFileHash + '\n' : '') +
808
+ (quality ? ' Rated quality: ' + quality + '\n' : '') +
809
+ ' Our rating: ' +
810
+ rating +
811
+ '/5 Stars\n' +
812
+ (comment ? ' Comment:\n ' + comment + '\n' : '');
813
+ return content;
814
+ };
815
+
816
+ export const parseRating = (content: string): Rating => {
817
+ const ratingRegex = new RegExp(
818
+ '' +
819
+ /^ Type: Rating\n/.source +
820
+ /(?: Subject type: (?<subjectType>[^\n]*?)\n)?/.source +
821
+ / Subject name: (?<subjectName>[^\n]*?)\n/.source +
822
+ /(?: URL that identifies the subject: (?<subjectReference>[^\n]*?)\n)?/.source +
823
+ /(?: Document file hash: (?<documentFileHash>[^\n]*?)\n)?/.source +
824
+ /(?: Rated quality: (?<quality>[^\n]*?)\n)?/.source +
825
+ / Our rating: (?<rating>[1-5])\/5 Stars\n/.source +
826
+ /(?: Comment:\n (?<comment>[\s\S]+?)\n)?/.source +
827
+ /$/.source
828
+ );
829
+ const match = content.match(ratingRegex);
830
+ if (!match || !match.groups) throw new Error('Invalid rating format: ' + content);
831
+
832
+ const {
833
+ subjectType,
834
+ subjectName,
835
+ subjectReference,
836
+ documentFileHash,
837
+ quality,
838
+ rating: ratingStr,
839
+ comment,
840
+ } = match.groups;
841
+
842
+ const rating = parseInt(ratingStr);
843
+ if (![1, 2, 3, 4, 5].includes(rating)) throw new Error('Invalid rating: ' + ratingStr);
844
+ if (
845
+ subjectType &&
846
+ ![
847
+ 'Organisation',
848
+ 'Policy proposal',
849
+ 'Regulation',
850
+ 'Treaty draft',
851
+ 'Product',
852
+ 'Research publication',
853
+ ].includes(subjectType)
854
+ )
855
+ throw new Error('Invalid subject type: ' + subjectType);
856
+ if (!subjectName) throw new Error('Missing subject name');
857
+
858
+ if (!isRatingValue(rating)) {
859
+ throw new Error('Invalid rating after validation: ' + rating);
860
+ }
861
+
862
+ return {
863
+ subjectType: subjectType as RatingSubjectTypeValue,
864
+ subjectName,
865
+ subjectReference,
866
+ documentFileHash,
867
+ quality,
868
+ rating,
869
+ comment,
870
+ };
871
+ };