fiftyone.pipeline.did 4.5.39 → 4.5.41
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/examples/fodIdExample.js +16 -6
- package/fodId.js +79 -8
- package/fodIdParseError.js +1 -1
- package/internal/layout.js +15 -0
- package/internal/terms.js +172 -0
- package/package.json +2 -2
- package/readme.md +82 -5
- package/tests/didClient.integration.test.js +127 -1
- package/tests/envelope.js +59 -3
- package/tests/fodId.test.js +302 -7
- package/types/fodId.d.ts +22 -0
- package/types/fodIdParseError.d.ts +1 -1
- package/types/internal/layout.d.ts +15 -0
- package/types/internal/terms.d.ts +39 -0
package/examples/fodIdExample.js
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
*
|
|
26
26
|
* The 51Degrees Cloud service issues real 51Dids. To keep this example
|
|
27
27
|
* self-contained and offline, it builds a sample 51Did in process - generate
|
|
28
|
-
* an ECDSA P-256 key pair, sign a canonical
|
|
29
|
-
*
|
|
28
|
+
* an ECDSA P-256 key pair, sign a canonical payload - then parses it back
|
|
29
|
+
* and prints the payload fields. It also shows the headline use
|
|
30
30
|
* case: a 51Did is re-issued fresh on every call (the envelope, hence the
|
|
31
31
|
* base64, changes), but the match key is stable. Compare match keys, never
|
|
32
32
|
* envelopes.
|
|
@@ -50,10 +50,14 @@ function uint32LE (v) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function samplePayload () {
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
53
|
+
// One byte longer than the least length, because the terms byte follows
|
|
54
|
+
// the match key. A 51Did whose payload stops at the match key carries
|
|
55
|
+
// no terms byte and reads as Terms.NOT_STATED.
|
|
56
|
+
const p = new Uint8Array(layout.PAYLOAD_LENGTH + layout.TERMS_LENGTH);
|
|
57
|
+
// Bits 6 and 7 are zero, so the type is Probabilistic. Bits 4 and 5 are
|
|
58
|
+
// zero, so the payload version is 0, which is the layout this package
|
|
59
|
+
// reads. Bits 0 to 2 are the usage and they are cumulative, so 0b011
|
|
60
|
+
// grants standard marketing and, with it, non-marketing use.
|
|
57
61
|
p[layout.FLAGS_OFFSET] = 0b0000_0011;
|
|
58
62
|
p[layout.LICENSE_ID_OFFSET] = 0x78;
|
|
59
63
|
p[layout.LICENSE_ID_OFFSET + 1] = 0x56;
|
|
@@ -62,6 +66,11 @@ function samplePayload () {
|
|
|
62
66
|
for (let i = 0; i < layout.MATCH_KEY_LENGTH; i++) {
|
|
63
67
|
p[layout.MATCH_KEY_OFFSET + i] = 0x20 + i;
|
|
64
68
|
}
|
|
69
|
+
// The terms document this sample was created under, being index 1, the
|
|
70
|
+
// Model Terms for Marketing version 2. The byte is an index into a table
|
|
71
|
+
// in the specification and is not a version number, and an issuer writes
|
|
72
|
+
// it for every marketing identifier.
|
|
73
|
+
p[layout.PAYLOAD_LENGTH] = 1;
|
|
65
74
|
return p;
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -107,6 +116,7 @@ async function run () {
|
|
|
107
116
|
console.log(' From cons.:', fodId.usageFromConsent);
|
|
108
117
|
console.log(' LicenseId :', fodId.licenseId);
|
|
109
118
|
console.log(' Match key :', Buffer.from(fodId.matchKey).toString('hex'));
|
|
119
|
+
console.log(' Terms :', fodId.terms);
|
|
110
120
|
console.log(' Verifies :', await fodId.verify(publicPem));
|
|
111
121
|
|
|
112
122
|
// Re-issue the same payload at a later time. The envelope differs and the
|
package/fodId.js
CHANGED
|
@@ -24,6 +24,7 @@ const owid = require('owid');
|
|
|
24
24
|
const layout = require('./internal/layout');
|
|
25
25
|
const IdType = require('./idType');
|
|
26
26
|
const Usage = require('./usage');
|
|
27
|
+
const Terms = require('./internal/terms');
|
|
27
28
|
const FodIdParseError = require('./fodIdParseError');
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -46,7 +47,15 @@ const ParseStatus = Object.freeze(Object.assign({}, owid.ParseStatus, {
|
|
|
46
47
|
* the match key that type carries after the header (16 GUID bytes for
|
|
47
48
|
* Random, 32 hash bytes for Probabilistic and HashedEmail).
|
|
48
49
|
*/
|
|
49
|
-
INVALID_TYPE_PAYLOAD_LENGTH: 'InvalidTypePayloadLength'
|
|
50
|
+
INVALID_TYPE_PAYLOAD_LENGTH: 'InvalidTypePayloadLength',
|
|
51
|
+
/**
|
|
52
|
+
* Bits 4 and 5 of the flags byte name a payload layout version this
|
|
53
|
+
* package does not know, so no field is read. A later version exists
|
|
54
|
+
* precisely because a field moved, so reading the payload under the
|
|
55
|
+
* layout this package knows would answer with values that are wrong
|
|
56
|
+
* rather than absent.
|
|
57
|
+
*/
|
|
58
|
+
UNSUPPORTED_PAYLOAD_VERSION: 'UnsupportedPayloadVersion'
|
|
50
59
|
}));
|
|
51
60
|
|
|
52
61
|
/**
|
|
@@ -144,6 +153,8 @@ class FodId {
|
|
|
144
153
|
this._licenseId = read.value._licenseId;
|
|
145
154
|
/** @type {Uint8Array} this identifier's own copy of the match key bytes */
|
|
146
155
|
this._matchKey = read.value._matchKey;
|
|
156
|
+
/** @type {number} the terms index, zero where the payload carries none */
|
|
157
|
+
this._termsIndex = read.value._termsIndex;
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
/**
|
|
@@ -333,6 +344,29 @@ class FodId {
|
|
|
333
344
|
return this._matchKey.slice();
|
|
334
345
|
}
|
|
335
346
|
|
|
347
|
+
/**
|
|
348
|
+
* The address of the terms document this 51Did was created under, read
|
|
349
|
+
* from the byte after the match key. The byte is an index into a table
|
|
350
|
+
* in the specification and this package turns the index into the
|
|
351
|
+
* address, so a caller never handles the byte. Nothing here fetches the
|
|
352
|
+
* address, because what to do with the document is the caller's
|
|
353
|
+
* decision.
|
|
354
|
+
*
|
|
355
|
+
* Null covers both an index of zero, which says the terms are not
|
|
356
|
+
* stated in the identifier, and an index added to the table after this
|
|
357
|
+
* package was released, which it cannot name. A caller cannot tell
|
|
358
|
+
* those two apart, which is deliberate, because both lead to the same
|
|
359
|
+
* place, being that the identifier does not say which terms it was
|
|
360
|
+
* created under and the answer has to come from somewhere else. No
|
|
361
|
+
* address is ever built from an index, since that would name a document
|
|
362
|
+
* nobody wrote.
|
|
363
|
+
* @returns {string|null} the address, or null where the identifier names
|
|
364
|
+
* no document this package knows, which is never an empty string
|
|
365
|
+
*/
|
|
366
|
+
get terms () {
|
|
367
|
+
return Terms.url(Terms.fromIndex(this._termsIndex));
|
|
368
|
+
}
|
|
369
|
+
|
|
336
370
|
/** @returns {number} the OWID version. */
|
|
337
371
|
get version () {
|
|
338
372
|
return this._owid.version;
|
|
@@ -421,14 +455,17 @@ class FodId {
|
|
|
421
455
|
* Reads the 51Did fields out of an envelope payload, answering with a
|
|
422
456
|
* status rather than throwing. This is the one walk of the payload, shared
|
|
423
457
|
* by every surface that reads a 51Did. The type is read from the header and
|
|
424
|
-
* decides the least the payload must hold after the header.
|
|
425
|
-
* the match key
|
|
426
|
-
* cloud, so a longer payload is
|
|
458
|
+
* decides the least the payload must hold after the header. The terms byte
|
|
459
|
+
* follows the match key, and anything beyond the terms byte is a creator
|
|
460
|
+
* context section whose lengths belong to the cloud, so a longer payload is
|
|
461
|
+
* accepted whatever its length.
|
|
427
462
|
* @param {Uint8Array} payload the payload bytes
|
|
428
463
|
* @returns {{status: string, flags?: number, licenseId?: number,
|
|
429
|
-
* matchKey?: Uint8Array,
|
|
430
|
-
*
|
|
431
|
-
* type
|
|
464
|
+
* matchKey?: Uint8Array, termsIndex?: number, length: number,
|
|
465
|
+
* required: number, type?: number, payloadVersion?: number}} `status`
|
|
466
|
+
* PARSED with the fields, or a 51Did status with the length the type
|
|
467
|
+
* needed, and the version found where that is what the payload was refused
|
|
468
|
+
* for
|
|
432
469
|
*/
|
|
433
470
|
function unpack (payload) {
|
|
434
471
|
const length = payload.length;
|
|
@@ -440,6 +477,20 @@ function unpack (payload) {
|
|
|
440
477
|
};
|
|
441
478
|
}
|
|
442
479
|
const flags = payload[layout.FLAGS_OFFSET];
|
|
480
|
+
// The version is read before any field, because a later version exists
|
|
481
|
+
// precisely because a field moved. Reading a payload of a version this
|
|
482
|
+
// package does not know under the layout it does know would answer with
|
|
483
|
+
// values that are wrong rather than absent, which is worse than
|
|
484
|
+
// refusing, and a version that nothing checks protects nothing.
|
|
485
|
+
const payloadVersion = (flags >> 4) & 0b11;
|
|
486
|
+
if (payloadVersion !== layout.SUPPORTED_PAYLOAD_VERSION) {
|
|
487
|
+
return {
|
|
488
|
+
status: ParseStatus.UNSUPPORTED_PAYLOAD_VERSION,
|
|
489
|
+
length,
|
|
490
|
+
required: layout.HEADER_LENGTH,
|
|
491
|
+
payloadVersion
|
|
492
|
+
};
|
|
493
|
+
}
|
|
443
494
|
// Little-endian unsigned 32-bit. `>>> 0` forces unsigned so the high bit
|
|
444
495
|
// does not produce a negative number.
|
|
445
496
|
const licenseId = (
|
|
@@ -468,6 +519,20 @@ function unpack (payload) {
|
|
|
468
519
|
type
|
|
469
520
|
};
|
|
470
521
|
}
|
|
522
|
+
// The terms byte sits after the match key, so where it sits follows the
|
|
523
|
+
// match key length the type selects. A payload with no byte to read is a
|
|
524
|
+
// terms index of zero, which says the terms are not stated, so absence
|
|
525
|
+
// and zero are the same answer and neither has to be told from the
|
|
526
|
+
// other.
|
|
527
|
+
//
|
|
528
|
+
// A Reserved type cannot carry a terms byte this reader can find, because
|
|
529
|
+
// the match key length for that type is not defined and every byte after
|
|
530
|
+
// the header is therefore the match key. Such an identifier reads as a
|
|
531
|
+
// terms index of zero, which is correct and is not a missing case here.
|
|
532
|
+
const termsOffset = layout.MATCH_KEY_OFFSET + matchKeyLength;
|
|
533
|
+
const termsIndex = termsOffset + layout.TERMS_LENGTH <= length
|
|
534
|
+
? payload[termsOffset]
|
|
535
|
+
: Terms.NOT_STATED;
|
|
471
536
|
return {
|
|
472
537
|
status: ParseStatus.PARSED,
|
|
473
538
|
flags,
|
|
@@ -475,6 +540,7 @@ function unpack (payload) {
|
|
|
475
540
|
// slice() copies, so the stored match key is this identifier's own.
|
|
476
541
|
matchKey: payload.slice(
|
|
477
542
|
layout.MATCH_KEY_OFFSET, layout.MATCH_KEY_OFFSET + matchKeyLength),
|
|
543
|
+
termsIndex,
|
|
478
544
|
length,
|
|
479
545
|
required
|
|
480
546
|
};
|
|
@@ -505,6 +571,7 @@ function readEnvelope (read) {
|
|
|
505
571
|
fodId._flags = unpacked.flags;
|
|
506
572
|
fodId._licenseId = unpacked.licenseId;
|
|
507
573
|
fodId._matchKey = unpacked.matchKey;
|
|
574
|
+
fodId._termsIndex = unpacked.termsIndex;
|
|
508
575
|
return { ok: true, value: fodId, status: ParseStatus.PARSED };
|
|
509
576
|
}
|
|
510
577
|
|
|
@@ -549,7 +616,7 @@ function valueOrThrow (read) {
|
|
|
549
616
|
}
|
|
550
617
|
|
|
551
618
|
/**
|
|
552
|
-
* The exception for a failed read. The
|
|
619
|
+
* The exception for a failed read. The three 51Did payload statuses keep the
|
|
553
620
|
* RangeError this package has always thrown for them, and every OWID status
|
|
554
621
|
* is a FodIdParseError carrying the status. Each error carries `status` so
|
|
555
622
|
* the reason can be acted on without reading the message.
|
|
@@ -567,6 +634,10 @@ function errorFor (read) {
|
|
|
567
634
|
`51Did payload for the ${IdType.name(read.detail.type)} type must be ` +
|
|
568
635
|
`at least ${read.detail.required} bytes, and ${read.detail.length} ` +
|
|
569
636
|
'were given.');
|
|
637
|
+
} else if (read.status === ParseStatus.UNSUPPORTED_PAYLOAD_VERSION) {
|
|
638
|
+
error = new RangeError(
|
|
639
|
+
`51Did payload version ${read.detail.payloadVersion} is not one this ` +
|
|
640
|
+
'package can read.');
|
|
570
641
|
} else {
|
|
571
642
|
return new FodIdParseError(read.status);
|
|
572
643
|
}
|
package/fodIdParseError.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* constructor) when the OWID library refused the envelope. The status names
|
|
27
27
|
* the reason in the same vocabulary the non-throwing surfaces report, so a
|
|
28
28
|
* caller catching this can act on the reason without reading the message.
|
|
29
|
-
* The
|
|
29
|
+
* The three 51Did payload statuses are thrown as RangeError instead, as this
|
|
30
30
|
* package has always thrown them, and that RangeError carries `status` too.
|
|
31
31
|
*/
|
|
32
32
|
class FodIdParseError extends Error {
|
package/internal/layout.js
CHANGED
|
@@ -49,10 +49,25 @@ module.exports = Object.freeze({
|
|
|
49
49
|
* identifiers, being a SHA-256.
|
|
50
50
|
*/
|
|
51
51
|
MATCH_KEY_LENGTH: 32,
|
|
52
|
+
/**
|
|
53
|
+
* Byte length of the terms field, which follows the match key. Its
|
|
54
|
+
* offset is not a constant here, because the match key length depends on
|
|
55
|
+
* the identifier type, so the offset is worked out from the type. A
|
|
56
|
+
* payload that ends at the match key carries no terms byte and reads as
|
|
57
|
+
* a terms index of zero, so the least payload lengths below do not
|
|
58
|
+
* include it.
|
|
59
|
+
*/
|
|
60
|
+
TERMS_LENGTH: 1,
|
|
52
61
|
/** Byte length of the flags and licence id fields together. */
|
|
53
62
|
HEADER_LENGTH: 5,
|
|
54
63
|
/** Byte length of the GUID match key carried by Random identifiers. */
|
|
55
64
|
GUID_LENGTH: 16,
|
|
65
|
+
/**
|
|
66
|
+
* The payload layout version this package reads, carried in bits 4 and
|
|
67
|
+
* 5 of the flags byte. Any other version is refused rather than read
|
|
68
|
+
* under this layout.
|
|
69
|
+
*/
|
|
70
|
+
SUPPORTED_PAYLOAD_VERSION: 0,
|
|
56
71
|
/** Least payload length for a Random identifier. */
|
|
57
72
|
RANDOM_PAYLOAD_LENGTH: 21,
|
|
58
73
|
/**
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/* *********************************************************************
|
|
2
|
+
* This Original Work is copyright of 51 Degrees Mobile Experts Limited.
|
|
3
|
+
* Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
|
|
4
|
+
* Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
|
|
5
|
+
*
|
|
6
|
+
* This Original Work is licensed under the European Union Public Licence
|
|
7
|
+
* (EUPL) v.1.2 and is subject to its terms as set out below.
|
|
8
|
+
*
|
|
9
|
+
* If a copy of the EUPL was not distributed with this file, You can obtain
|
|
10
|
+
* one at https://opensource.org/licenses/EUPL-1.2.
|
|
11
|
+
*
|
|
12
|
+
* The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
|
|
13
|
+
* amended by the European Commission) shall be deemed incompatible for
|
|
14
|
+
* the purposes of the Work and the provisions of the compatibility
|
|
15
|
+
* clause in Article 5 of the EUPL shall not apply.
|
|
16
|
+
*
|
|
17
|
+
* If using the Work as, or as part of, a network application, by
|
|
18
|
+
* including the attribution notice(s) required under Article 5 of the EUPL
|
|
19
|
+
* in the end user terms of the application under an appropriate heading,
|
|
20
|
+
* such notice(s) shall fulfill the requirements of that article.
|
|
21
|
+
* ********************************************************************* */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The terms document a 51Did was created under, carried in the byte that
|
|
25
|
+
* follows the match key. A 51Did created for marketing may only be used by
|
|
26
|
+
* a receiver that has accepted the terms it was created under, so the terms
|
|
27
|
+
* have to travel with the identifier rather than beside it, because an
|
|
28
|
+
* identifier passed as a query string parameter arrives on its own and any
|
|
29
|
+
* hop can drop what was sent alongside it without the identifier looking
|
|
30
|
+
* any different.
|
|
31
|
+
*
|
|
32
|
+
* The byte is an index into a table in the specification and is not a
|
|
33
|
+
* version number, so that a later document can live at any address rather
|
|
34
|
+
* than only at an address a number could be turned into. A new document is
|
|
35
|
+
* a new index, and every package has to be released to know it, which is
|
|
36
|
+
* the cost of a receiver being able to trust what it reads. An index is
|
|
37
|
+
* never reused or repointed once published, because repointing one would
|
|
38
|
+
* rewrite what a past identifier says it agreed to.
|
|
39
|
+
*
|
|
40
|
+
* An identifier whose payload ends at the match key carries no terms byte
|
|
41
|
+
* and reads as NOT_STATED, which is the right answer for it because no
|
|
42
|
+
* terms are stated in it. An identifier of the Reserved type
|
|
43
|
+
* reads as NOT_STATED too, because the match key length for that type is
|
|
44
|
+
* not defined, so every byte after the header is the match key and no byte
|
|
45
|
+
* is left for a reader to find.
|
|
46
|
+
*
|
|
47
|
+
* This module is internal to the package. It sits beside layout.js on the
|
|
48
|
+
* internal path, and is not exported from the package entry point nor
|
|
49
|
+
* reachable as a subpath, because the exports map in package.json offers
|
|
50
|
+
* only the entry point. The package turns the index into the address that
|
|
51
|
+
* fodId.terms answers with, so a caller never handles the byte, and the
|
|
52
|
+
* names here are the ones the specification gives so that every package
|
|
53
|
+
* describes one document the same way.
|
|
54
|
+
*
|
|
55
|
+
* UNKNOWN is an index added after this package was released, so the package
|
|
56
|
+
* cannot name the document. It answers with no address, as NOT_STATED does,
|
|
57
|
+
* because no package may build an address from an index it does not know,
|
|
58
|
+
* since that would name a document nobody wrote.
|
|
59
|
+
*
|
|
60
|
+
* NOT_STATED does not mean the identifier is unrestricted. It means only
|
|
61
|
+
* that the identifier does not carry the answer, so the answer has to come
|
|
62
|
+
* from the data accompanying it, being the Terms Document Locator in an
|
|
63
|
+
* OpenRTB request or whatever the surrounding protocol offers. Where both
|
|
64
|
+
* are present and they disagree, the identifier's own value is the one that
|
|
65
|
+
* describes the identifier, because it is inside the signature and the
|
|
66
|
+
* accompanying data is not.
|
|
67
|
+
*
|
|
68
|
+
* The usage says where an identifier may go and the terms say which
|
|
69
|
+
* document it was created under, so a receiver needs both. An identifier
|
|
70
|
+
* created for non-marketing carries NOT_STATED, since the Model Terms
|
|
71
|
+
* govern marketing use, and it stays barred from a demand source by its
|
|
72
|
+
* usage.
|
|
73
|
+
*
|
|
74
|
+
* url answers with the address for an index this package knows and null for
|
|
75
|
+
* every other value. This package never fetches the address, because what
|
|
76
|
+
* to do with the document is the receiver's decision.
|
|
77
|
+
*
|
|
78
|
+
* The table, and the rule that an index this package does not know is not
|
|
79
|
+
* zero, are specified once for all languages at
|
|
80
|
+
* https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md
|
|
81
|
+
* which is the authority rather than this comment.
|
|
82
|
+
*/
|
|
83
|
+
// The terms table from the specification, which is the whole of the
|
|
84
|
+
// definition of which index is which document. It is published at
|
|
85
|
+
// https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md#terms
|
|
86
|
+
// and this is the only place in the shipped code that carries it. The
|
|
87
|
+
// tests write the address out again on purpose, so that a test never
|
|
88
|
+
// compares the reader with itself.
|
|
89
|
+
//
|
|
90
|
+
// One row per terms document, at the position of the index the payload
|
|
91
|
+
// carries, holding the name for it and the address it stands for. A new
|
|
92
|
+
// terms document is one new row here and one named value below, and nothing
|
|
93
|
+
// else in the package changes. The name and the address sit in the same row
|
|
94
|
+
// so that they cannot be added apart, which two lists side by side allowed.
|
|
95
|
+
//
|
|
96
|
+
// Index 0 has a row because it is a named value the specification gives,
|
|
97
|
+
// and its address is null because it names no document.
|
|
98
|
+
//
|
|
99
|
+
// Each address names an exact version rather than a landing page, because a
|
|
100
|
+
// document at an unversioned address can be edited afterwards and a
|
|
101
|
+
// receiver has to know the document that was in force when the identifier
|
|
102
|
+
// was made.
|
|
103
|
+
const TABLE = [
|
|
104
|
+
{ name: 'NotStated', url: null },
|
|
105
|
+
{ name: 'ModelTermsForMarketing2', url: 'https://m4ow.uk/mtm/2.txt' }
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
// Not a row, because it stands for every index the table does not carry and
|
|
109
|
+
// so has no index of its own. A Terms index read from a payload is one byte,
|
|
110
|
+
// so it is 0 to 255 and can never be negative.
|
|
111
|
+
const UNKNOWN = -1;
|
|
112
|
+
const UNKNOWN_NAME = 'Unknown';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The table row a Terms value stands for, and null where the value is not
|
|
116
|
+
* a row, being UNKNOWN or anything else outside the table. Every lookup
|
|
117
|
+
* goes through here so that the bounds are decided once and no lookup
|
|
118
|
+
* subscripts the table with a value it has not checked, which would raise
|
|
119
|
+
* at the caller rather than answer with no address.
|
|
120
|
+
* @param {number} terms a Terms value
|
|
121
|
+
* @returns {{name: string, url: string|null}|null} the row, or null
|
|
122
|
+
*/
|
|
123
|
+
function rowFor (terms) {
|
|
124
|
+
return terms >= 0 && terms < TABLE.length ? TABLE[terms] : null;
|
|
125
|
+
}
|
|
126
|
+
const Terms = Object.freeze({
|
|
127
|
+
/**
|
|
128
|
+
* An index this package does not know, being one added to the table
|
|
129
|
+
* after this package was released. It answers with no address, because
|
|
130
|
+
* no address may be built from an index the package cannot name.
|
|
131
|
+
*/
|
|
132
|
+
UNKNOWN,
|
|
133
|
+
/**
|
|
134
|
+
* The terms are not stated in the identifier, which is also how an
|
|
135
|
+
* identifier whose payload ends at the match key reads. The answer has
|
|
136
|
+
* to come from the data accompanying the identifier.
|
|
137
|
+
*/
|
|
138
|
+
NOT_STATED: 0,
|
|
139
|
+
/** The Model Terms for Marketing, version 2, at https://m4ow.uk/mtm/2.txt. */
|
|
140
|
+
MODEL_TERMS_FOR_MARKETING_2: 1,
|
|
141
|
+
/**
|
|
142
|
+
* The Terms value for a raw index byte, being UNKNOWN for every index
|
|
143
|
+
* this package does not know.
|
|
144
|
+
* @param {number} index the 1-byte terms index (0-255)
|
|
145
|
+
* @returns {number} the Terms value
|
|
146
|
+
*/
|
|
147
|
+
fromIndex (index) {
|
|
148
|
+
return rowFor(index) === null ? UNKNOWN : index;
|
|
149
|
+
},
|
|
150
|
+
/**
|
|
151
|
+
* The cross language name of a Terms value.
|
|
152
|
+
* @param {number} terms a Terms value
|
|
153
|
+
* @returns {string} for example "ModelTermsForMarketing2"
|
|
154
|
+
*/
|
|
155
|
+
name (terms) {
|
|
156
|
+
const row = rowFor(terms);
|
|
157
|
+
return row === null ? UNKNOWN_NAME : row.name;
|
|
158
|
+
},
|
|
159
|
+
/**
|
|
160
|
+
* The address of the terms document a Terms value stands for, or null
|
|
161
|
+
* for NOT_STATED and for UNKNOWN. Never an empty string, and never an
|
|
162
|
+
* address built from the index. The address is returned and never
|
|
163
|
+
* fetched.
|
|
164
|
+
* @param {number} terms a Terms value
|
|
165
|
+
* @returns {string|null} for example "https://m4ow.uk/mtm/2.txt"
|
|
166
|
+
*/
|
|
167
|
+
url (terms) {
|
|
168
|
+
const row = rowFor(terms);
|
|
169
|
+
return row === null ? null : row.url;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
module.exports = Terms;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fiftyone.pipeline.did",
|
|
3
|
-
"version": "4.5.
|
|
4
|
-
"description": "Strongly typed reader for the 51Did (51Degrees Identifier) value returned by the 51Degrees Cloud service. Parses the OWID envelope and exposes the usage, licence id
|
|
3
|
+
"version": "4.5.41",
|
|
4
|
+
"description": "Strongly typed reader for the 51Did (51Degrees Identifier) value returned by the 51Degrees Cloud service. Parses the OWID envelope and exposes the usage, licence id, match key and terms document plus the identifier type. Compare match keys, never envelopes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"51degrees",
|
|
7
7
|
"51did",
|
package/readme.md
CHANGED
|
@@ -34,8 +34,9 @@ question. The summary here explains what the accessors report, and the
|
|
|
34
34
|
specification governs wherever the two differ.
|
|
35
35
|
|
|
36
36
|
The payload carries a one byte Flags field, a four byte little-endian
|
|
37
|
-
LicenseId and then
|
|
38
|
-
identifier type, which decides how long the match key is
|
|
37
|
+
LicenseId, the match key and then a one byte Terms. Bits 6 and 7 of the
|
|
38
|
+
flags name the identifier type, which decides how long the match key is,
|
|
39
|
+
and so where the Terms byte sits.
|
|
39
40
|
|
|
40
41
|
| Bits 7-6 | `IdType` | Match key length | Least payload accepted |
|
|
41
42
|
|---------:|-----------------|-----------------:|-----------------------:|
|
|
@@ -58,6 +59,27 @@ the lower bound for the identifier type, holds no upper bound of its own, and
|
|
|
58
59
|
leaves anything longer for the cloud to judge. A reader built before a longer
|
|
59
60
|
context section existed therefore still reads the identifier.
|
|
60
61
|
|
|
62
|
+
The Terms byte sits between the match key and the creator context. An
|
|
63
|
+
identifier whose payload ends at the match key carries no terms byte, and
|
|
64
|
+
the reader answers with a terms index of zero for one.
|
|
65
|
+
|
|
66
|
+
## The payload version
|
|
67
|
+
|
|
68
|
+
Bits 4 and 5 of the flags byte say which payload layout the identifier
|
|
69
|
+
follows, and this package reads version 0. A payload naming version 1, 2 or
|
|
70
|
+
3 is refused with `ParseStatus.UNSUPPORTED_PAYLOAD_VERSION`, and the errors
|
|
71
|
+
the throwing surfaces raise name the version they found.
|
|
72
|
+
|
|
73
|
+
No field is read under the layout this package knows once the version says
|
|
74
|
+
otherwise. A later version exists precisely because a field moved, so
|
|
75
|
+
reading such a payload here would answer with values that are wrong rather
|
|
76
|
+
than absent, which is worse than refusing. A version that nothing checks
|
|
77
|
+
protects nothing.
|
|
78
|
+
|
|
79
|
+
The version is not exposed. Either this package read the layout, in which
|
|
80
|
+
case the accessors are the answer, or it did not, in which case there is no
|
|
81
|
+
identifier to read fields from.
|
|
82
|
+
|
|
61
83
|
## The usage a 51Did was created for
|
|
62
84
|
|
|
63
85
|
Every 51Did says what it was created for, and `fodId.usage` reports it as one
|
|
@@ -88,6 +110,57 @@ usage it is.
|
|
|
88
110
|
`"NonMarketing"`, and `Usage.idUsage(usage)` gives the cloud's own `id.usage`
|
|
89
111
|
value, for example `"non-marketing"`, or `null` for `NONE`.
|
|
90
112
|
|
|
113
|
+
## The terms a 51Did was created under
|
|
114
|
+
|
|
115
|
+
A 51Did created for marketing may only be used by a receiver that has
|
|
116
|
+
accepted the terms it was created under, so the terms travel inside the
|
|
117
|
+
identifier rather than beside it. An identifier passed as a query string
|
|
118
|
+
parameter arrives on its own, and any hop can drop what was sent alongside it
|
|
119
|
+
without the identifier looking any different.
|
|
120
|
+
|
|
121
|
+
The byte after the match key is an index into a table in the specification
|
|
122
|
+
and is not a version number, so that a later document can live at any address
|
|
123
|
+
rather than only at an address a number could be turned into. An index is
|
|
124
|
+
never reused or repointed once published, because repointing one would
|
|
125
|
+
rewrite what a past identifier says it agreed to.
|
|
126
|
+
|
|
127
|
+
| Index | Document | `fodId.terms` |
|
|
128
|
+
| ---: | --- | --- |
|
|
129
|
+
| `0` | Not stated in the identifier | `null` |
|
|
130
|
+
| `1` | Model Terms for Marketing, version 2 | `https://m4ow.uk/mtm/2.txt` |
|
|
131
|
+
| anything else | One this package cannot name | `null` |
|
|
132
|
+
|
|
133
|
+
`fodId.terms` is the address of the document. The package turns the index
|
|
134
|
+
into the address, so you never handle the byte. Nothing here fetches the
|
|
135
|
+
address, because what to do with the document is your decision.
|
|
136
|
+
|
|
137
|
+
**No address is ever built from an index this package cannot name**, because
|
|
138
|
+
that would name a document nobody wrote and a receiver would record having
|
|
139
|
+
accepted terms that do not exist. An index of zero and an index added after
|
|
140
|
+
this package was released therefore give the same answer, which you cannot
|
|
141
|
+
tell apart, and that is deliberate, since both say the identifier does not
|
|
142
|
+
give the terms and the answer has to come from somewhere else.
|
|
143
|
+
|
|
144
|
+
No address does not mean the identifier is unrestricted. It means only that
|
|
145
|
+
the identifier does not carry the answer, so the answer has to come from the
|
|
146
|
+
data accompanying it, being the Terms Document Locator in an OpenRTB request
|
|
147
|
+
or whatever the surrounding protocol offers. Carrying the terms does not
|
|
148
|
+
remove the need to carry a Terms Document Locator where a protocol has one,
|
|
149
|
+
and where both are present and they disagree the identifier's own value is
|
|
150
|
+
the one that describes the identifier, because it is inside the signature and
|
|
151
|
+
the accompanying data is not.
|
|
152
|
+
|
|
153
|
+
The usage says where an identifier may go and the terms say which document it
|
|
154
|
+
was created under, so both are needed. An identifier created for
|
|
155
|
+
non-marketing carries `NOT_STATED`, since the Model Terms govern marketing
|
|
156
|
+
use, and it stays barred from a demand source by its usage.
|
|
157
|
+
|
|
158
|
+
An identifier whose payload ends at the match key, and one of the
|
|
159
|
+
`RESERVED` type, both read as `NOT_STATED`. The first carries no byte after
|
|
160
|
+
the match key and the second exposes every byte after the header as the
|
|
161
|
+
match key, so neither leaves a byte for the reader to find, and no terms
|
|
162
|
+
are stated in either.
|
|
163
|
+
|
|
91
164
|
## Reading a 51Did
|
|
92
165
|
|
|
93
166
|
A 51Did arrives from outside, from a page, a link or a log line, so a value
|
|
@@ -124,7 +197,7 @@ reads successfully and then fails verification. Verify with
|
|
|
124
197
|
|
|
125
198
|
`FodId.ParseStatus` is a frozen object of stable string values. Compare
|
|
126
199
|
against its members rather than against the text of any message. The
|
|
127
|
-
vocabulary is the OWID library's own, carried through unchanged, plus
|
|
200
|
+
vocabulary is the OWID library's own, carried through unchanged, plus three
|
|
128
201
|
members for the 51Did payload. A failure the OWID library reported keeps the
|
|
129
202
|
OWID library's status, so a specific reason is never reduced to a general one.
|
|
130
203
|
|
|
@@ -143,6 +216,7 @@ OWID library's status, so a specific reason is never reduced to a general one.
|
|
|
143
216
|
| `MALFORMED_ENVELOPE` | OWID | Malformed in a way none of the above describes |
|
|
144
217
|
| `PAYLOAD_TOO_SHORT` | 51Did | The payload is shorter than the 5 byte header (flags and licence id), so the type cannot be read |
|
|
145
218
|
| `INVALID_TYPE_PAYLOAD_LENGTH` | 51Did | The header named a type and the payload is shorter than that type's match key needs, being 21 bytes for Random and 37 for Probabilistic and HashedEmail |
|
|
219
|
+
| `UNSUPPORTED_PAYLOAD_VERSION` | 51Did | Bits 4 and 5 of the flags byte name a payload layout version this package does not know, so no field is read |
|
|
146
220
|
|
|
147
221
|
A Reserved type is not yet assigned, so the reader accepts it at any length
|
|
148
222
|
from the header up and exposes whatever follows the header as the match key.
|
|
@@ -156,7 +230,7 @@ exception. They run the same checks, in the same order, and throw:
|
|
|
156
230
|
| Thrown | When |
|
|
157
231
|
| --- | --- |
|
|
158
232
|
| `TypeError` | The argument is the wrong kind of thing, being `null`, `undefined`, a non-string to `fromBase64`, or a non-`Uint8Array` to `fromByteArray` |
|
|
159
|
-
| `RangeError` | The payload is `PAYLOAD_TOO_SHORT` or `
|
|
233
|
+
| `RangeError` | The payload is `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH` or `UNSUPPORTED_PAYLOAD_VERSION`, being the three statuses the 51Did payload rules produce. The error carries `status` |
|
|
160
234
|
| `FodIdParseError` | The OWID library refused the envelope for any other status. The error carries `status` |
|
|
161
235
|
|
|
162
236
|
A wrong argument type is a programming error and stays exceptional on every
|
|
@@ -236,7 +310,7 @@ npm test
|
|
|
236
310
|
## Usage
|
|
237
311
|
|
|
238
312
|
```js
|
|
239
|
-
const { FodId, IdType, Usage } = require('fiftyone.pipeline.did');
|
|
313
|
+
const { FodId, IdType, Usage, Terms } = require('fiftyone.pipeline.did');
|
|
240
314
|
|
|
241
315
|
// Either base64 alphabet is accepted, the standard one the cloud issues and
|
|
242
316
|
// the URL-safe one a page puts in a link, with or without padding.
|
|
@@ -247,6 +321,9 @@ const fromConsent = fodId.usageFromConsent;
|
|
|
247
321
|
const type = fodId.type; // IdType.PROBABILISTIC / RANDOM / HASHED_EMAIL
|
|
248
322
|
const licenseId = fodId.licenseId;
|
|
249
323
|
const matchKey = fodId.matchKey; // Uint8Array: SHA-256 or GUID bytes, see type
|
|
324
|
+
const terms = fodId.terms; // address of the terms document it was
|
|
325
|
+
// created under, null where it names none
|
|
326
|
+
// this package knows
|
|
250
327
|
|
|
251
328
|
const domain = fodId.domain;
|
|
252
329
|
const minutes = fodId.date; // minutes since 2020-01-01T00:00:00Z
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// FOD_CLOUD_API_URL as the client does.
|
|
28
28
|
|
|
29
29
|
const {
|
|
30
|
-
FodId, DidClient, ContextResult, DidNotSupportedError
|
|
30
|
+
FodId, DidClient, ContextResult, DidNotSupportedError, Usage, IdType
|
|
31
31
|
} = require('../index');
|
|
32
32
|
|
|
33
33
|
const resourceKey = process.env._51DEGREES_RESOURCE_KEY ||
|
|
@@ -92,4 +92,130 @@ live('DidClient against the cloud', () => {
|
|
|
92
92
|
expect(result.statusCode).toBe(200);
|
|
93
93
|
expect(result.context).toBe(ContextResult.UNREADABLE);
|
|
94
94
|
});
|
|
95
|
+
// The versioned Model Terms for Marketing document a marketing 51Did is
|
|
96
|
+
// created under. Written out here rather than read from the package,
|
|
97
|
+
// because a test that asked the package what it expects would agree with
|
|
98
|
+
// itself whatever the package said. The literal is what a receiver has to
|
|
99
|
+
// be able to fetch.
|
|
100
|
+
const MODEL_TERMS_FOR_MARKETING_2 = 'https://m4ow.uk/mtm/2.txt';
|
|
101
|
+
|
|
102
|
+
// Each id.usage the service offers, the usage this package must answer
|
|
103
|
+
// with, and the terms the identifier must carry. A non-marketing
|
|
104
|
+
// identifier may not reach a demand source at all, so there is nothing
|
|
105
|
+
// for a receiver to agree to and it states no terms.
|
|
106
|
+
const usages = [
|
|
107
|
+
['non-marketing', Usage.NON_MARKETING, null],
|
|
108
|
+
['standard', Usage.STANDARD, MODEL_TERMS_FOR_MARKETING_2],
|
|
109
|
+
['personalized', Usage.PERSONALIZED, MODEL_TERMS_FOR_MARKETING_2]
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
// IAB TCF v2 consent strings, and the usage the service must derive from
|
|
113
|
+
// each without the caller stating one. The first grants all twelve
|
|
114
|
+
// purposes, the second the Appendix 1 standard set of 1, 2, 7, 8 and 11.
|
|
115
|
+
// These are the strings the cloud's own IabTcfElement tests use, repeated
|
|
116
|
+
// here rather than shared, for the same reason as the address above.
|
|
117
|
+
const consentStrings = [
|
|
118
|
+
['AAAAAAAAAAAAAAAAAAAAAAAAAP_w', Usage.PERSONALIZED],
|
|
119
|
+
['AAAAAAAAAAAAAAAAAAAAAAAAAMMg', Usage.STANDARD]
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
// Asks the json endpoint for a 51Did with the given query fragment and
|
|
123
|
+
// returns every identifier it answered with. An empty array means the
|
|
124
|
+
// resource key is not entitled to that usage, which is reported by the
|
|
125
|
+
// caller rather than failed.
|
|
126
|
+
const identifiersFor = async (fragment) => {
|
|
127
|
+
const url = client.endpoint + 'json?resource=' +
|
|
128
|
+
encodeURIComponent(resourceKey) + '&' + fragment +
|
|
129
|
+
'&values=FODiD.IdProbGlobal&values=FODiD.IdProbLic';
|
|
130
|
+
const response = await fetch(url, {
|
|
131
|
+
headers: { 'User-Agent': 'fiftyone.pipeline.did tests' }
|
|
132
|
+
});
|
|
133
|
+
const body = await response.text();
|
|
134
|
+
if (response.status !== 200) {
|
|
135
|
+
throw new Error(`json answered HTTP ${response.status}: ${body}`);
|
|
136
|
+
}
|
|
137
|
+
const fodid = JSON.parse(body).fodid;
|
|
138
|
+
if (!fodid) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
return ['idprobglobal', 'idproblic']
|
|
142
|
+
.map((name) => fodid[name])
|
|
143
|
+
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
144
|
+
.map((value) => FodId.fromBase64(value));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Every field the flags byte carries, plus the terms, read through the
|
|
148
|
+
// accessors rather than by masking. The usage values are cumulative,
|
|
149
|
+
// being 001, 011 and 111, so a caller masking the byte for the
|
|
150
|
+
// non-marketing bit reads every marketing identifier as non-marketing.
|
|
151
|
+
// The label travels into the expectation so a failure names which
|
|
152
|
+
// identifier it was.
|
|
153
|
+
const assertAligned = (label, id, usage, terms, fromConsent) => {
|
|
154
|
+
expect({ label, usage: id.usage }).toEqual({ label, usage });
|
|
155
|
+
expect({ label, fromConsent: id.usageFromConsent })
|
|
156
|
+
.toEqual({ label, fromConsent });
|
|
157
|
+
expect({ label, terms: id.terms }).toEqual({ label, terms });
|
|
158
|
+
expect({ label, type: id.type })
|
|
159
|
+
.toEqual({ label, type: IdType.PROBABILISTIC });
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
test('every usage reads back the terms and flags the service wrote',
|
|
163
|
+
async () => {
|
|
164
|
+
let checked = 0;
|
|
165
|
+
for (const [name, usage, terms] of usages) {
|
|
166
|
+
const identifiers = await identifiersFor(
|
|
167
|
+
'id.usage=' + encodeURIComponent(name));
|
|
168
|
+
if (identifiers.length === 0) {
|
|
169
|
+
console.warn(`id.usage=${name}: no identifier returned, so this ` +
|
|
170
|
+
'key is not entitled to that usage.');
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
identifiers.forEach((id, index) => {
|
|
174
|
+
assertAligned(`${name}[${index}]`, id, usage, terms, false);
|
|
175
|
+
});
|
|
176
|
+
// Only a marketing usage carries a terms address, so only a
|
|
177
|
+
// marketing identifier shows that the service wrote the byte. A
|
|
178
|
+
// non-marketing one states no terms either way, which is the same
|
|
179
|
+
// answer a service predating the Terms release would give.
|
|
180
|
+
if (terms !== null) {
|
|
181
|
+
checked += identifiers.length;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (checked === 0) {
|
|
185
|
+
console.warn('NOTHING PROVEN: this resource key returned no ' +
|
|
186
|
+
'marketing 51Did, so no terms address was read. Use a key ' +
|
|
187
|
+
'entitled to the standard or personalized usage.');
|
|
188
|
+
} else {
|
|
189
|
+
console.log(`Terms checked on ${checked} marketing identifier(s).`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a consent string sets the usage-from-consent bit', async () => {
|
|
194
|
+
let proven = 0;
|
|
195
|
+
for (const [tcString, usage] of consentStrings) {
|
|
196
|
+
// No id.usage is sent. A stated usage wins over a consent string, so
|
|
197
|
+
// sending one would leave the bit clear and prove the opposite.
|
|
198
|
+
const identifiers = await identifiersFor(
|
|
199
|
+
'tcstring=' + encodeURIComponent(tcString));
|
|
200
|
+
if (identifiers.length === 0) {
|
|
201
|
+
console.warn(`consent string granting ${usage}: no identifier ` +
|
|
202
|
+
'returned, so this key is not entitled to that marketing usage.');
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
identifiers.forEach((id, index) => {
|
|
206
|
+
// A consent string granting a marketing usage produces a marketing
|
|
207
|
+
// identifier, so the terms travel with it too.
|
|
208
|
+
assertAligned(`consent/${usage}[${index}]`, id, usage,
|
|
209
|
+
MODEL_TERMS_FOR_MARKETING_2, true);
|
|
210
|
+
});
|
|
211
|
+
proven += identifiers.length;
|
|
212
|
+
}
|
|
213
|
+
if (proven === 0) {
|
|
214
|
+
console.warn('NOTHING PROVEN: this resource key returned no ' +
|
|
215
|
+
'identifier for either consent string, so the usage-from-consent ' +
|
|
216
|
+
'bit was never read.');
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`Usage-from-consent read on ${proven} identifier(s).`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
95
221
|
});
|
package/tests/envelope.js
CHANGED
|
@@ -32,7 +32,13 @@ const VERSION = 2;
|
|
|
32
32
|
const SIGNED_VERSION = 3;
|
|
33
33
|
const DOMAIN = '51degrees.com';
|
|
34
34
|
const DATE = 2900000; // minutes since 2020-01-01
|
|
35
|
-
|
|
35
|
+
// HashedEmail type tag in bits 6-7, payload version 0 in bits 4-5 and
|
|
36
|
+
// the personalized marketing usage in bits 0-2.
|
|
37
|
+
const CANONICAL_FLAGS = 0x85;
|
|
38
|
+
// The terms index a marketing identifier carries, being the Model Terms
|
|
39
|
+
// for Marketing version 2, and the zero a non-marketing one carries.
|
|
40
|
+
const MARKETING_TERMS_INDEX = 1;
|
|
41
|
+
const NON_MARKETING_TERMS_INDEX = 0;
|
|
36
42
|
const CANONICAL_LICENSE_ID = 0x12345678;
|
|
37
43
|
const OWID_EPOCH_MS = Date.UTC(2020, 0, 1);
|
|
38
44
|
|
|
@@ -50,7 +56,10 @@ function writeLicenseId (payload) {
|
|
|
50
56
|
payload[layout.LICENSE_ID_OFFSET + 3] = 0x12;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
// The canonical payload cut off at the end of the match key, so it carries
|
|
60
|
+
// no terms byte. A reader takes that as a terms index of zero, and this is
|
|
61
|
+
// the fixture for that rule rather than anything an issuer would write.
|
|
62
|
+
function payloadEndingAtMatchKey () {
|
|
54
63
|
const p = new Uint8Array(layout.PAYLOAD_LENGTH);
|
|
55
64
|
p[layout.FLAGS_OFFSET] = CANONICAL_FLAGS;
|
|
56
65
|
writeLicenseId(p);
|
|
@@ -58,7 +67,16 @@ function canonicalPayload () {
|
|
|
58
67
|
return p;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
|
|
70
|
+
// The canonical payload as an issuer writes one, carrying the payload
|
|
71
|
+
// version 0 in its flags byte and the terms byte of the document a
|
|
72
|
+
// personalized marketing identifier is created under. This is the creating
|
|
73
|
+
// side, so it writes every field an issuer writes.
|
|
74
|
+
function canonicalPayload () {
|
|
75
|
+
return withTerms(payloadEndingAtMatchKey(), MARKETING_TERMS_INDEX);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The canonical Random payload cut off at the end of its GUID.
|
|
79
|
+
function randomPayloadEndingAtMatchKey () {
|
|
62
80
|
const p = new Uint8Array(layout.RANDOM_PAYLOAD_LENGTH);
|
|
63
81
|
p[layout.FLAGS_OFFSET] = (1 << 6) | 0b001; // Random tag + usage bits
|
|
64
82
|
writeLicenseId(p);
|
|
@@ -68,6 +86,38 @@ function canonicalRandomPayload () {
|
|
|
68
86
|
return p;
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
// The canonical Random payload as an issuer writes one, carrying the zero
|
|
90
|
+
// terms byte a non-marketing identifier carries.
|
|
91
|
+
function canonicalRandomPayload () {
|
|
92
|
+
return withTerms(
|
|
93
|
+
randomPayloadEndingAtMatchKey(), NON_MARKETING_TERMS_INDEX);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The same payload with its version bits set to the given version, leaving
|
|
97
|
+
// every other bit of the flags byte alone.
|
|
98
|
+
function withPayloadVersion (payload, version) {
|
|
99
|
+
const p = payload.slice();
|
|
100
|
+
p[layout.FLAGS_OFFSET] =
|
|
101
|
+
(payload[layout.FLAGS_OFFSET] & 0b11001111) | (version << 4);
|
|
102
|
+
return p;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The same payload with a terms byte written after the match key, and a
|
|
106
|
+
// creator context section of contextLength bytes after that where one is
|
|
107
|
+
// asked for. The payload given must end at the match key, as both the
|
|
108
|
+
// canonical builders above do, so that the byte lands at the offset a
|
|
109
|
+
// reader works out from the identifier type.
|
|
110
|
+
function withTerms (payload, index, contextLength = 0) {
|
|
111
|
+
const p = new Uint8Array(
|
|
112
|
+
payload.length + layout.TERMS_LENGTH + contextLength);
|
|
113
|
+
p.set(payload);
|
|
114
|
+
p[payload.length] = index;
|
|
115
|
+
if (contextLength > 0) {
|
|
116
|
+
p.fill(0xCC, payload.length + layout.TERMS_LENGTH);
|
|
117
|
+
}
|
|
118
|
+
return p;
|
|
119
|
+
}
|
|
120
|
+
|
|
71
121
|
function uint32LE (v) {
|
|
72
122
|
return [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
|
|
73
123
|
}
|
|
@@ -157,11 +207,17 @@ module.exports = {
|
|
|
157
207
|
DOMAIN,
|
|
158
208
|
DATE,
|
|
159
209
|
CANONICAL_FLAGS,
|
|
210
|
+
MARKETING_TERMS_INDEX,
|
|
211
|
+
NON_MARKETING_TERMS_INDEX,
|
|
160
212
|
CANONICAL_LICENSE_ID,
|
|
161
213
|
DUMMY_SIG,
|
|
162
214
|
canonicalMatchKey,
|
|
163
215
|
canonicalPayload,
|
|
164
216
|
canonicalRandomPayload,
|
|
217
|
+
payloadEndingAtMatchKey,
|
|
218
|
+
randomPayloadEndingAtMatchKey,
|
|
219
|
+
withTerms,
|
|
220
|
+
withPayloadVersion,
|
|
165
221
|
noSigBytes,
|
|
166
222
|
envelopeBytes,
|
|
167
223
|
envelopeBase64,
|
package/tests/fodId.test.js
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
|
|
23
23
|
const owid = require('owid');
|
|
24
24
|
const { FodId, FodIdParseError, IdType, Usage } = require('../index');
|
|
25
|
+
// The named value is internal to the package and is not exported, so the
|
|
26
|
+
// table test below reaches the module directly rather than through the
|
|
27
|
+
// package entry point.
|
|
28
|
+
const Terms = require('../internal/terms');
|
|
25
29
|
const {
|
|
26
30
|
DOMAIN,
|
|
27
31
|
DATE,
|
|
@@ -31,6 +35,10 @@ const {
|
|
|
31
35
|
canonicalMatchKey,
|
|
32
36
|
canonicalPayload,
|
|
33
37
|
canonicalRandomPayload,
|
|
38
|
+
payloadEndingAtMatchKey,
|
|
39
|
+
randomPayloadEndingAtMatchKey,
|
|
40
|
+
withTerms,
|
|
41
|
+
withPayloadVersion,
|
|
34
42
|
envelopeBytes,
|
|
35
43
|
envelopeBase64,
|
|
36
44
|
signedVerifiable,
|
|
@@ -40,6 +48,14 @@ const layout = require('../internal/layout');
|
|
|
40
48
|
|
|
41
49
|
const { ParseStatus, SignatureStatus } = FodId;
|
|
42
50
|
|
|
51
|
+
// Written out here rather than taken from the package, so that the test
|
|
52
|
+
// fails if the address the package answers with ever changes. An index is
|
|
53
|
+
// never repointed once published, because repointing one would rewrite what
|
|
54
|
+
// a past identifier says it agreed to.
|
|
55
|
+
const MODEL_TERMS_2_URL = 'https://m4ow.uk/mtm/2.txt';
|
|
56
|
+
// An index added to the table after this package was released.
|
|
57
|
+
const UNKNOWN_INDEX = 200;
|
|
58
|
+
|
|
43
59
|
describe('FodId', () => {
|
|
44
60
|
// ----- Current .NET coverage -----
|
|
45
61
|
|
|
@@ -133,10 +149,13 @@ describe('FodId', () => {
|
|
|
133
149
|
expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(0);
|
|
134
150
|
});
|
|
135
151
|
|
|
136
|
-
test('
|
|
152
|
+
test('every flags bit outside the version is read unchanged', () => {
|
|
153
|
+
// Bits 4 and 5 are the payload version and only version 0 is read, so
|
|
154
|
+
// every other bit is set and those two are left clear. A payload with
|
|
155
|
+
// them set is refused rather than read, which the version tests cover.
|
|
137
156
|
const p = canonicalPayload();
|
|
138
|
-
p[layout.FLAGS_OFFSET] =
|
|
139
|
-
expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(
|
|
157
|
+
p[layout.FLAGS_OFFSET] = 0xCF;
|
|
158
|
+
expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(0xCF);
|
|
140
159
|
});
|
|
141
160
|
|
|
142
161
|
test('matchKey is a defensive copy', () => {
|
|
@@ -323,6 +342,270 @@ describe('FodId', () => {
|
|
|
323
342
|
expect(fod.matchKey.length).toBe(0);
|
|
324
343
|
});
|
|
325
344
|
|
|
345
|
+
// ----- Terms -----
|
|
346
|
+
|
|
347
|
+
// The terms document the identifier was created under, carried in the
|
|
348
|
+
// byte after the match key. Absence and zero are the same answer, and an
|
|
349
|
+
// index this package does not know is neither of them.
|
|
350
|
+
|
|
351
|
+
test('a payload ending at the match key answers with no address', () => {
|
|
352
|
+
// There is no byte after the match key to read, so the reader answers
|
|
353
|
+
// zero and everything else about the identifier reads as it does when
|
|
354
|
+
// the byte is present.
|
|
355
|
+
const fod = FodId.fromBase64(
|
|
356
|
+
envelopeBase64(payloadEndingAtMatchKey()));
|
|
357
|
+
expect(fod.terms).toBeNull();
|
|
358
|
+
expect(fod.matchKey).toEqual(canonicalMatchKey());
|
|
359
|
+
expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('terms index one is the Model Terms for Marketing address', () => {
|
|
363
|
+
const fod = FodId.fromBase64(
|
|
364
|
+
envelopeBase64(withTerms(payloadEndingAtMatchKey(), 1)));
|
|
365
|
+
expect(fod.terms).toBe(MODEL_TERMS_2_URL);
|
|
366
|
+
expect(fod.matchKey).toEqual(canonicalMatchKey());
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('an index this package does not know has no address', () => {
|
|
370
|
+
// No address is ever built from an index the package cannot name,
|
|
371
|
+
// because that would name a document nobody wrote and a receiver
|
|
372
|
+
// would record having accepted terms that do not exist.
|
|
373
|
+
for (const index of [2, 127, UNKNOWN_INDEX, 255]) {
|
|
374
|
+
const fod = FodId.fromBase64(
|
|
375
|
+
envelopeBase64(withTerms(payloadEndingAtMatchKey(), index)));
|
|
376
|
+
expect(fod.terms).toBeNull();
|
|
377
|
+
expect(fod.terms).not.toBe('');
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('not stated and an unknown index both answer with no address',
|
|
382
|
+
() => {
|
|
383
|
+
// A caller cannot tell the two apart, which is deliberate, since
|
|
384
|
+
// both say the identifier does not give the terms and the answer
|
|
385
|
+
// has to come from somewhere else.
|
|
386
|
+
const notStated = FodId.fromBase64(
|
|
387
|
+
envelopeBase64(withTerms(payloadEndingAtMatchKey(), 0)));
|
|
388
|
+
const unknown = FodId.fromBase64(
|
|
389
|
+
envelopeBase64(withTerms(payloadEndingAtMatchKey(), UNKNOWN_INDEX)));
|
|
390
|
+
expect(notStated.terms).toBeNull();
|
|
391
|
+
expect(unknown.terms).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('a zero terms byte written out reads the same as none at all', () => {
|
|
395
|
+
const written = FodId.fromBase64(
|
|
396
|
+
envelopeBase64(withTerms(payloadEndingAtMatchKey(), 0)));
|
|
397
|
+
const absent = FodId.fromBase64(
|
|
398
|
+
envelopeBase64(payloadEndingAtMatchKey()));
|
|
399
|
+
expect(written.terms).toBe(absent.terms);
|
|
400
|
+
expect(written.terms).toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// The byte follows the match key, so its offset comes from the identifier
|
|
404
|
+
// type. Reading it at a fixed offset would read a context byte on one
|
|
405
|
+
// type and the wrong end of the match key on the other.
|
|
406
|
+
test.each([
|
|
407
|
+
['a 32 byte match key', payloadEndingAtMatchKey,
|
|
408
|
+
layout.MATCH_KEY_LENGTH],
|
|
409
|
+
['a 16 byte match key', randomPayloadEndingAtMatchKey,
|
|
410
|
+
layout.GUID_LENGTH]
|
|
411
|
+
])('the terms byte is read after %s', (name, build, matchKeyLength) => {
|
|
412
|
+
const bare = FodId.fromBase64(envelopeBase64(build()));
|
|
413
|
+
expect(bare.matchKey.length).toBe(matchKeyLength);
|
|
414
|
+
expect(bare.terms).toBeNull();
|
|
415
|
+
|
|
416
|
+
for (const [index, url] of [
|
|
417
|
+
[0, null],
|
|
418
|
+
[1, MODEL_TERMS_2_URL],
|
|
419
|
+
[UNKNOWN_INDEX, null]
|
|
420
|
+
]) {
|
|
421
|
+
const fod = FodId.fromBase64(envelopeBase64(withTerms(build(), index)));
|
|
422
|
+
expect(fod.matchKey).toEqual(bare.matchKey);
|
|
423
|
+
expect(fod.matchKey.length).toBe(matchKeyLength);
|
|
424
|
+
expect(fod.terms).toBe(url);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test.each([
|
|
429
|
+
['a 32 byte match key', payloadEndingAtMatchKey],
|
|
430
|
+
['a 16 byte match key', randomPayloadEndingAtMatchKey]
|
|
431
|
+
])('a context section after the terms byte leaves it read, with %s',
|
|
432
|
+
(name, build) => {
|
|
433
|
+
// The byte sits before the creator context, so a payload carrying
|
|
434
|
+
// both proves the byte is read at its own offset rather than at the
|
|
435
|
+
// end of whatever the payload holds.
|
|
436
|
+
const p = withTerms(build(), 1, 96);
|
|
437
|
+
const bytes = envelopeBytes(p);
|
|
438
|
+
const encoded = Buffer.from(bytes).toString('base64');
|
|
439
|
+
const bare = FodId.fromBase64(envelopeBase64(build()));
|
|
440
|
+
|
|
441
|
+
for (const fod of [
|
|
442
|
+
FodId.fromBase64(encoded),
|
|
443
|
+
FodId.fromByteArray(bytes),
|
|
444
|
+
FodId.fromOwid(owid.parse(encoded).owid),
|
|
445
|
+
FodId.tryParse(encoded).value,
|
|
446
|
+
FodId.tryFromByteArray(bytes).value
|
|
447
|
+
]) {
|
|
448
|
+
expect(fod.matchKey).toEqual(bare.matchKey);
|
|
449
|
+
expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
|
|
450
|
+
expect(fod.terms).toBe(MODEL_TERMS_2_URL);
|
|
451
|
+
expect(fod.payload).toHaveLength(p.length);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('a Reserved payload answers with no address', () => {
|
|
456
|
+
// A Reserved type is not yet assigned, so everything after the header
|
|
457
|
+
// is exposed as the match key and no byte is left to read as the terms.
|
|
458
|
+
const p = new Uint8Array(layout.MATCH_KEY_OFFSET);
|
|
459
|
+
p[layout.FLAGS_OFFSET] = 0b1100_0000;
|
|
460
|
+
const fod = FodId.fromBase64(envelopeBase64(p));
|
|
461
|
+
expect(fod.type).toBe(IdType.RESERVED);
|
|
462
|
+
expect(fod.terms).toBeNull();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('the terms survive both base64 alphabets and the byte round-trip',
|
|
466
|
+
() => {
|
|
467
|
+
const p = withTerms(payloadEndingAtMatchKey(), 1);
|
|
468
|
+
const first = FodId.fromBase64(envelopeBase64(p));
|
|
469
|
+
for (const again of [
|
|
470
|
+
FodId.fromBase64(first.asBase64()),
|
|
471
|
+
FodId.fromBase64(first.asBase64Url()),
|
|
472
|
+
FodId.fromByteArray(first.asByteArray())
|
|
473
|
+
]) {
|
|
474
|
+
expect(again.terms).toBe(first.terms);
|
|
475
|
+
expect(again.terms).toBe(MODEL_TERMS_2_URL);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('every row of the Terms table agrees with itself', () => {
|
|
480
|
+
// The row carries the name and the address together, so this fails if
|
|
481
|
+
// a later change puts either somewhere else and the two disagree, or
|
|
482
|
+
// if a row is added without an address.
|
|
483
|
+
for (let index = 0; index < 256; index++) {
|
|
484
|
+
const terms = Terms.fromIndex(index);
|
|
485
|
+
const name = Terms.name(terms);
|
|
486
|
+
const url = Terms.url(terms);
|
|
487
|
+
if (terms === Terms.UNKNOWN) {
|
|
488
|
+
expect(name).toBe('Unknown');
|
|
489
|
+
expect(url).toBeNull();
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
// A row is reached by its own index and answers a name.
|
|
493
|
+
expect(terms).toBe(index);
|
|
494
|
+
expect(typeof name).toBe('string');
|
|
495
|
+
expect(name.length).toBeGreaterThan(0);
|
|
496
|
+
if (index === Terms.NOT_STATED) {
|
|
497
|
+
// Names no document, so it has no address.
|
|
498
|
+
expect(url).toBeNull();
|
|
499
|
+
} else {
|
|
500
|
+
expect(typeof url).toBe('string');
|
|
501
|
+
expect(url.startsWith('https://')).toBe(true);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('the Terms table maps every index, name and address', () => {
|
|
507
|
+
expect(Terms.fromIndex(0)).toBe(Terms.NOT_STATED);
|
|
508
|
+
expect(Terms.fromIndex(1)).toBe(Terms.MODEL_TERMS_FOR_MARKETING_2);
|
|
509
|
+
expect(Terms.fromIndex(2)).toBe(Terms.UNKNOWN);
|
|
510
|
+
expect(Terms.fromIndex(UNKNOWN_INDEX)).toBe(Terms.UNKNOWN);
|
|
511
|
+
expect(Terms.fromIndex(255)).toBe(Terms.UNKNOWN);
|
|
512
|
+
expect(Terms.name(Terms.NOT_STATED)).toBe('NotStated');
|
|
513
|
+
expect(Terms.name(Terms.MODEL_TERMS_FOR_MARKETING_2))
|
|
514
|
+
.toBe('ModelTermsForMarketing2');
|
|
515
|
+
expect(Terms.name(Terms.UNKNOWN)).toBe('Unknown');
|
|
516
|
+
expect(Terms.url(Terms.NOT_STATED)).toBeNull();
|
|
517
|
+
expect(Terms.url(Terms.MODEL_TERMS_FOR_MARKETING_2))
|
|
518
|
+
.toBe(MODEL_TERMS_2_URL);
|
|
519
|
+
expect(Terms.url(Terms.UNKNOWN)).toBeNull();
|
|
520
|
+
// A value that is not a row answers rather than raising, so a lookup
|
|
521
|
+
// never reaches the caller as a TypeError from a table subscript.
|
|
522
|
+
expect(Terms.url(99)).toBeNull();
|
|
523
|
+
expect(Terms.name(99)).toBe('Unknown');
|
|
524
|
+
expect(Terms.url(-2)).toBeNull();
|
|
525
|
+
expect(Object.isFrozen(Terms)).toBe(true);
|
|
526
|
+
// The named value is internal, so the package entry point does not
|
|
527
|
+
// offer it. The address on FodId is the whole of the surface.
|
|
528
|
+
expect(require('../index').Terms).toBeUndefined();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ----- The payload version -----
|
|
532
|
+
|
|
533
|
+
// Bits 4 and 5 of the flags byte say which payload layout the identifier
|
|
534
|
+
// follows. This package reads version 0 and refuses every other version
|
|
535
|
+
// rather than reading fields that may have moved.
|
|
536
|
+
|
|
537
|
+
test('a flags byte with the version bits clear reads every field', () => {
|
|
538
|
+
const fod = FodId.fromBase64(envelopeBase64(canonicalPayload()));
|
|
539
|
+
expect(fod.type).toBe(IdType.HASHED_EMAIL);
|
|
540
|
+
expect(fod.usage).toBe(Usage.PERSONALIZED);
|
|
541
|
+
expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
|
|
542
|
+
expect(fod.matchKey).toEqual(canonicalMatchKey());
|
|
543
|
+
expect(fod.terms).toBe(MODEL_TERMS_2_URL);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test.each([[1], [2], [3]])(
|
|
547
|
+
'payload version %i is refused and names the version', (version) => {
|
|
548
|
+
const p = withPayloadVersion(canonicalPayload(), version);
|
|
549
|
+
const read = FodId.tryParse(envelopeBase64(p));
|
|
550
|
+
|
|
551
|
+
expect(read.ok).toBe(false);
|
|
552
|
+
expect(read.status).toBe(ParseStatus.UNSUPPORTED_PAYLOAD_VERSION);
|
|
553
|
+
// Nothing is handed back, rather than a value with some fields
|
|
554
|
+
// filled in, because there is no identifier to expose fields for
|
|
555
|
+
// when the layout was not understood.
|
|
556
|
+
expect(read.value).toBeNull();
|
|
557
|
+
|
|
558
|
+
let thrown = null;
|
|
559
|
+
try {
|
|
560
|
+
FodId.fromBase64(envelopeBase64(p));
|
|
561
|
+
} catch (error) {
|
|
562
|
+
thrown = error;
|
|
563
|
+
}
|
|
564
|
+
expect(thrown).not.toBeNull();
|
|
565
|
+
expect(thrown.status).toBe(ParseStatus.UNSUPPORTED_PAYLOAD_VERSION);
|
|
566
|
+
expect(thrown.message).toContain(`version ${version}`);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test('the version is read apart from the usage and type bits', () => {
|
|
570
|
+
// A reader masking the wrong bits would refuse a version 0 identifier
|
|
571
|
+
// or let a later version through, so every combination is tried.
|
|
572
|
+
for (const usage of [0b000, 0b001, 0b011, 0b111]) {
|
|
573
|
+
for (const type of [0b00, 0b10, 0b11]) {
|
|
574
|
+
const p = payloadEndingAtMatchKey();
|
|
575
|
+
p[layout.FLAGS_OFFSET] = (type << 6) | usage;
|
|
576
|
+
|
|
577
|
+
expect(FodId.tryParse(envelopeBase64(p)).ok).toBe(true);
|
|
578
|
+
|
|
579
|
+
for (const version of [1, 2, 3]) {
|
|
580
|
+
const refused = FodId.tryParse(
|
|
581
|
+
envelopeBase64(withPayloadVersion(p, version)));
|
|
582
|
+
expect(refused.status)
|
|
583
|
+
.toBe(ParseStatus.UNSUPPORTED_PAYLOAD_VERSION);
|
|
584
|
+
expect(refused.value).toBeNull();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('reading the address does not fetch it', () => {
|
|
591
|
+
// The package answers with the address and never fetches it, because
|
|
592
|
+
// what to do with the document is the caller's decision.
|
|
593
|
+
const before = globalThis.fetch;
|
|
594
|
+
const calls = [];
|
|
595
|
+
globalThis.fetch = (...args) => {
|
|
596
|
+
calls.push(args);
|
|
597
|
+
return Promise.reject(new Error('a read must not fetch'));
|
|
598
|
+
};
|
|
599
|
+
try {
|
|
600
|
+
const fod = FodId.fromBase64(
|
|
601
|
+
envelopeBase64(withTerms(payloadEndingAtMatchKey(), 1)));
|
|
602
|
+
expect(fod.terms).toBe(MODEL_TERMS_2_URL);
|
|
603
|
+
} finally {
|
|
604
|
+
globalThis.fetch = before;
|
|
605
|
+
}
|
|
606
|
+
expect(calls).toHaveLength(0);
|
|
607
|
+
});
|
|
608
|
+
|
|
326
609
|
// ----- Gap tests (runbook section 6b) -----
|
|
327
610
|
|
|
328
611
|
test('compare two 51Dids over the same payload', () => {
|
|
@@ -531,7 +814,7 @@ describe('FodId.tryParse and tryFromByteArray', () => {
|
|
|
531
814
|
}
|
|
532
815
|
}
|
|
533
816
|
|
|
534
|
-
test('the status vocabulary is the OWID one plus the
|
|
817
|
+
test('the status vocabulary is the OWID one plus the three 51Did members', () => {
|
|
535
818
|
expect(Object.isFrozen(ParseStatus)).toBe(true);
|
|
536
819
|
for (const [name, value] of Object.entries(owid.ParseStatus)) {
|
|
537
820
|
expect(ParseStatus[name]).toBe(value);
|
|
@@ -539,8 +822,10 @@ describe('FodId.tryParse and tryFromByteArray', () => {
|
|
|
539
822
|
expect(ParseStatus.PAYLOAD_TOO_SHORT).toBe('PayloadTooShort');
|
|
540
823
|
expect(ParseStatus.INVALID_TYPE_PAYLOAD_LENGTH)
|
|
541
824
|
.toBe('InvalidTypePayloadLength');
|
|
825
|
+
expect(ParseStatus.UNSUPPORTED_PAYLOAD_VERSION)
|
|
826
|
+
.toBe('UnsupportedPayloadVersion');
|
|
542
827
|
expect(Object.keys(ParseStatus))
|
|
543
|
-
.toHaveLength(Object.keys(owid.ParseStatus).length +
|
|
828
|
+
.toHaveLength(Object.keys(owid.ParseStatus).length + 3);
|
|
544
829
|
expect(SignatureStatus).toBe(owid.SignatureStatus);
|
|
545
830
|
});
|
|
546
831
|
|
|
@@ -649,7 +934,7 @@ describe('FodId.tryParse and tryFromByteArray', () => {
|
|
|
649
934
|
});
|
|
650
935
|
|
|
651
936
|
test('an OWID declaration mismatch is propagated unchanged without any cryptography', () => {
|
|
652
|
-
const bytes = envelopeBytes(
|
|
937
|
+
const bytes = envelopeBytes(payloadEndingAtMatchKey());
|
|
653
938
|
const at = lengthFieldOffset();
|
|
654
939
|
expect(bytes[at]).toBe(layout.PAYLOAD_LENGTH); // the field under test
|
|
655
940
|
bytes[at] = layout.PAYLOAD_LENGTH + 1; // declares one byte more than sent
|
|
@@ -754,7 +1039,7 @@ describe('FodId.tryParse and tryFromByteArray', () => {
|
|
|
754
1039
|
expect(() => FodId.fromByteArray(null)).toThrow(TypeError);
|
|
755
1040
|
expect(() => FodId.fromByteArray('QUJD')).toThrow(TypeError);
|
|
756
1041
|
|
|
757
|
-
// The
|
|
1042
|
+
// The three 51Did payload statuses stay RangeError, now carrying the
|
|
758
1043
|
// status as well.
|
|
759
1044
|
const tooShort = envelopeBase64(new Uint8Array(3));
|
|
760
1045
|
expect(() => FodId.fromBase64(tooShort)).toThrow(RangeError);
|
|
@@ -770,6 +1055,16 @@ describe('FodId.tryParse and tryFromByteArray', () => {
|
|
|
770
1055
|
}));
|
|
771
1056
|
expect(() => FodId.fromByteArray(envelopeBytes(new Uint8Array(0))))
|
|
772
1057
|
.toThrow(RangeError);
|
|
1058
|
+
// A refused payload version is the third of them, and is a RangeError
|
|
1059
|
+
// and not a FodIdParseError, because the version is a rule this package
|
|
1060
|
+
// applies to the payload rather than anything the OWID library judged.
|
|
1061
|
+
const wrongVersion = envelopeBase64(
|
|
1062
|
+
withPayloadVersion(canonicalPayload(), 1));
|
|
1063
|
+
expect(() => FodId.fromBase64(wrongVersion)).toThrow(RangeError);
|
|
1064
|
+
expect(() => FodId.fromBase64(wrongVersion)).toThrow(
|
|
1065
|
+
expect.objectContaining({
|
|
1066
|
+
status: ParseStatus.UNSUPPORTED_PAYLOAD_VERSION
|
|
1067
|
+
}));
|
|
773
1068
|
|
|
774
1069
|
// An OWID status is a FodIdParseError carrying that status.
|
|
775
1070
|
expect(() => FodId.fromBase64('****')).toThrow(FodIdParseError);
|
package/types/fodId.d.ts
CHANGED
|
@@ -172,6 +172,8 @@ declare class FodId {
|
|
|
172
172
|
_licenseId: number;
|
|
173
173
|
/** @type {Uint8Array} this identifier's own copy of the match key bytes */
|
|
174
174
|
_matchKey: Uint8Array;
|
|
175
|
+
/** @type {number} the terms index, zero where the payload carries none */
|
|
176
|
+
_termsIndex: number;
|
|
175
177
|
/** @returns {number} the IdType carried in bits 6-7 of the flags. */
|
|
176
178
|
get type(): number;
|
|
177
179
|
/**
|
|
@@ -210,6 +212,26 @@ declare class FodId {
|
|
|
210
212
|
* @returns {Uint8Array} a defensive copy of the match key bytes
|
|
211
213
|
*/
|
|
212
214
|
get matchKey(): Uint8Array;
|
|
215
|
+
/**
|
|
216
|
+
* The address of the terms document this 51Did was created under, read
|
|
217
|
+
* from the byte after the match key. The byte is an index into a table
|
|
218
|
+
* in the specification and this package turns the index into the
|
|
219
|
+
* address, so a caller never handles the byte. Nothing here fetches the
|
|
220
|
+
* address, because what to do with the document is the caller's
|
|
221
|
+
* decision.
|
|
222
|
+
*
|
|
223
|
+
* Null covers both an index of zero, which says the terms are not
|
|
224
|
+
* stated in the identifier, and an index added to the table after this
|
|
225
|
+
* package was released, which it cannot name. A caller cannot tell
|
|
226
|
+
* those two apart, which is deliberate, because both lead to the same
|
|
227
|
+
* place, being that the identifier does not say which terms it was
|
|
228
|
+
* created under and the answer has to come from somewhere else. No
|
|
229
|
+
* address is ever built from an index, since that would name a document
|
|
230
|
+
* nobody wrote.
|
|
231
|
+
* @returns {string|null} the address, or null where the identifier names
|
|
232
|
+
* no document this package knows, which is never an empty string
|
|
233
|
+
*/
|
|
234
|
+
get terms(): string | null;
|
|
213
235
|
/** @returns {number} the OWID version. */
|
|
214
236
|
get version(): number;
|
|
215
237
|
/** @returns {string} the domain of the OWID creator. */
|
|
@@ -5,7 +5,7 @@ export = FodIdParseError;
|
|
|
5
5
|
* constructor) when the OWID library refused the envelope. The status names
|
|
6
6
|
* the reason in the same vocabulary the non-throwing surfaces report, so a
|
|
7
7
|
* caller catching this can act on the reason without reading the message.
|
|
8
|
-
* The
|
|
8
|
+
* The three 51Did payload statuses are thrown as RangeError instead, as this
|
|
9
9
|
* package has always thrown them, and that RangeError carries `status` too.
|
|
10
10
|
*/
|
|
11
11
|
declare class FodIdParseError extends Error {
|
|
@@ -12,10 +12,25 @@ declare const _exports: Readonly<{
|
|
|
12
12
|
* identifiers, being a SHA-256.
|
|
13
13
|
*/
|
|
14
14
|
MATCH_KEY_LENGTH: 32;
|
|
15
|
+
/**
|
|
16
|
+
* Byte length of the terms field, which follows the match key. Its
|
|
17
|
+
* offset is not a constant here, because the match key length depends on
|
|
18
|
+
* the identifier type, so the offset is worked out from the type. A
|
|
19
|
+
* payload that ends at the match key carries no terms byte and reads as
|
|
20
|
+
* a terms index of zero, so the least payload lengths below do not
|
|
21
|
+
* include it.
|
|
22
|
+
*/
|
|
23
|
+
TERMS_LENGTH: 1;
|
|
15
24
|
/** Byte length of the flags and licence id fields together. */
|
|
16
25
|
HEADER_LENGTH: 5;
|
|
17
26
|
/** Byte length of the GUID match key carried by Random identifiers. */
|
|
18
27
|
GUID_LENGTH: 16;
|
|
28
|
+
/**
|
|
29
|
+
* The payload layout version this package reads, carried in bits 4 and
|
|
30
|
+
* 5 of the flags byte. Any other version is refused rather than read
|
|
31
|
+
* under this layout.
|
|
32
|
+
*/
|
|
33
|
+
SUPPORTED_PAYLOAD_VERSION: 0;
|
|
19
34
|
/** Least payload length for a Random identifier. */
|
|
20
35
|
RANDOM_PAYLOAD_LENGTH: 21;
|
|
21
36
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export = Terms;
|
|
2
|
+
declare const Terms: Readonly<{
|
|
3
|
+
/**
|
|
4
|
+
* An index this package does not know, being one added to the table
|
|
5
|
+
* after this package was released. It answers with no address, because
|
|
6
|
+
* no address may be built from an index the package cannot name.
|
|
7
|
+
*/
|
|
8
|
+
UNKNOWN: -1;
|
|
9
|
+
/**
|
|
10
|
+
* The terms are not stated in the identifier, which is also how an
|
|
11
|
+
* identifier whose payload ends at the match key reads. The answer has
|
|
12
|
+
* to come from the data accompanying the identifier.
|
|
13
|
+
*/
|
|
14
|
+
NOT_STATED: 0;
|
|
15
|
+
/** The Model Terms for Marketing, version 2, at https://m4ow.uk/mtm/2.txt. */
|
|
16
|
+
MODEL_TERMS_FOR_MARKETING_2: 1;
|
|
17
|
+
/**
|
|
18
|
+
* The Terms value for a raw index byte, being UNKNOWN for every index
|
|
19
|
+
* this package does not know.
|
|
20
|
+
* @param {number} index the 1-byte terms index (0-255)
|
|
21
|
+
* @returns {number} the Terms value
|
|
22
|
+
*/
|
|
23
|
+
fromIndex(index: number): number;
|
|
24
|
+
/**
|
|
25
|
+
* The cross language name of a Terms value.
|
|
26
|
+
* @param {number} terms a Terms value
|
|
27
|
+
* @returns {string} for example "ModelTermsForMarketing2"
|
|
28
|
+
*/
|
|
29
|
+
name(terms: number): string;
|
|
30
|
+
/**
|
|
31
|
+
* The address of the terms document a Terms value stands for, or null
|
|
32
|
+
* for NOT_STATED and for UNKNOWN. Never an empty string, and never an
|
|
33
|
+
* address built from the index. The address is returned and never
|
|
34
|
+
* fetched.
|
|
35
|
+
* @param {number} terms a Terms value
|
|
36
|
+
* @returns {string|null} for example "https://m4ow.uk/mtm/2.txt"
|
|
37
|
+
*/
|
|
38
|
+
url(terms: number): string | null;
|
|
39
|
+
}>;
|