fiftyone.pipeline.did 4.5.39 → 4.5.40

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.
@@ -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 37-byte payload - then parses it
29
- * back and prints the three payload fields. It also shows the headline use
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
- const p = new Uint8Array(layout.PAYLOAD_LENGTH);
54
- // Bits 6 and 7 are zero, so the type is Probabilistic. Bits 0 to 2 are
55
- // the usage and they are cumulative, so 0b011 grants standard
56
- // marketing and, with it, non-marketing use.
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. Anything beyond
425
- * the match key is a creator context section whose lengths belong to the
426
- * cloud, so a longer payload is accepted whatever its length.
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, length: number, required: number, type?: number}}
430
- * `status` PARSED with the fields, or a 51Did status with the length the
431
- * type needed
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 two 51Did payload statuses keep 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
  }
@@ -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 two 51Did payload statuses are thrown as RangeError instead, as this
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 {
@@ -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.39",
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 and match key plus the identifier type. Compare match keys, never envelopes.",
3
+ "version": "4.5.40",
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 the match key. Bits 6 and 7 of the flags name the
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 two
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 `INVALID_TYPE_PAYLOAD_LENGTH`. The error carries `status` |
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
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
- const CANONICAL_FLAGS = 0xA5; // HashedEmail type tag + usage bits
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
- function canonicalPayload () {
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
- function canonicalRandomPayload () {
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,
@@ -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('a flags byte with every bit set is read unchanged', () => {
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] = 0xFF;
139
- expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(255);
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 two 51Did members', () => {
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 + 2);
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(canonicalPayload());
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 two 51Did payload statuses stay RangeError, now carrying 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 two 51Did payload statuses are thrown as RangeError instead, as this
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
+ }>;