From 926d5ac11b32e13a3aac90a23ae05e46ce443675 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 16 Sep 2026 20:51:01 +0100 Subject: [PATCH 1/4] FEAT: Read the four platform and browser factors, rename usageIsIndirect and refuse usage bits 000 Cloud release 4.4.38 replaced the single browser factor of the creator context with platformname, platformversion, browsername and browserversion. RedeemResult.factors now carries only the nine factor names in the new Factor constant, in the order the cloud lists them, so the old browser key is no longer read into it. The body as sent stays in raw. The flags byte rules in specifications pull request 30 are applied. FodId.usageFromConsent is renamed to FodId.usageIsIndirect with no alias, and its documentation now says direct against indirect, with a consent string as the only indirect signal today. Usage.NONE is removed, and a payload whose usage bits are all clear is refused with the new ParseStatus.NO_USAGE, thrown as a RangeError naming the bits by the throwing surfaces. The patterns 010, 100, 101 and 110 read as before. The readme, the example and the generated types follow. --- fiftyone.pipeline.did/didClient.js | 59 +++++++++-- .../examples/fodIdExample.js | 2 +- fiftyone.pipeline.did/fodId.js | 54 +++++++--- fiftyone.pipeline.did/fodIdParseError.js | 2 +- fiftyone.pipeline.did/index.js | 2 + fiftyone.pipeline.did/readme.md | 47 ++++++--- .../tests/didClient.integration.test.js | 12 +-- fiftyone.pipeline.did/tests/didClient.test.js | 73 +++++++++++++- fiftyone.pipeline.did/tests/fodId.test.js | 99 ++++++++++++++++--- fiftyone.pipeline.did/types/didClient.d.ts | 31 +++++- fiftyone.pipeline.did/types/fodId.d.ts | 16 +-- .../types/fodIdParseError.d.ts | 2 +- fiftyone.pipeline.did/types/index.d.ts | 3 +- fiftyone.pipeline.did/types/usage.d.ts | 26 ++--- fiftyone.pipeline.did/usage.js | 33 ++++--- 15 files changed, 365 insertions(+), 96 deletions(-) diff --git a/fiftyone.pipeline.did/didClient.js b/fiftyone.pipeline.did/didClient.js index 66fec9e..3eab916 100644 --- a/fiftyone.pipeline.did/didClient.js +++ b/fiftyone.pipeline.did/didClient.js @@ -125,6 +125,28 @@ const FactorResult = Object.freeze({ MISCONFIGURED: 'misconfigured' }); +/** + * The names of the creator context factors, as the cloud writes them as + * keys of `factors`, in the order the cloud lists them. The operating + * system and the browser each have a name and a version, so a version + * mismatch beside a verified name reads as an upgrade, and a mismatched + * name reads as a different operating system or browser. These four + * replaced the single `browser` factor from cloud release 4.4.38. + */ +const Factor = Object.freeze({ + TRANSPORT: 'transport', + DEVICE: 'device', + BROWSER_IP: 'browserip', + CONNECTION_IP: 'connectionip', + ASN: 'asn', + PLATFORM_NAME: 'platformname', + PLATFORM_VERSION: 'platformversion', + BROWSER_NAME: 'browsername', + BROWSER_VERSION: 'browserversion' +}); + +const FACTOR_NAMES = Object.freeze(Object.values(Factor)); + /** * The reason a {@link DidClient#verifySignatureDetailed} answer was given. */ @@ -233,14 +255,16 @@ class RedeemResult { ? SignatureResult.INVALID : SignatureResult.UNKNOWN; /** - * @type {object | undefined} factor name to {@link FactorResult} value - * (or null where nothing was compared), present only when the cloud - * sent `factors`, which is the mismatch outcome. The names are - * transport, device, browserip, connectionip, asn and browser. + * @type {object | undefined} {@link Factor} name to + * {@link FactorResult} value (or null where nothing was compared), + * present only when the cloud sent `factors`, which it does where there + * is something to diagnose, being a mismatch or a misconfigured result + * that still compared some factors. Only the names in {@link Factor} + * are carried, in that order, so a name this package does not know, + * such as the `browser` factor the four platform and browser factors + * replaced, is not read into it. `raw` still holds the body as sent. */ - this.factors = parsed.factors && typeof parsed.factors === 'object' - ? Object.freeze(Object.assign({}, parsed.factors)) - : undefined; + this.factors = readFactors(parsed.factors); const verifiedAt = typeof parsed.verifiedAt === 'string' ? new Date(parsed.verifiedAt) : null; @@ -304,6 +328,26 @@ class RedeemResult { } } +/** + * Reads the factors the cloud reported, keeping only the names in + * {@link Factor} and in that order. + * @param {*} factors the `factors` value of a parsed body + * @returns {object | undefined} a frozen object, or undefined where the + * body carried no factors object + */ +function readFactors (factors) { + if (!factors || typeof factors !== 'object' || Array.isArray(factors)) { + return undefined; + } + const read = {}; + for (const name of FACTOR_NAMES) { + if (Object.prototype.hasOwnProperty.call(factors, name)) { + read[name] = factors[name]; + } + } + return Object.freeze(read); +} + /** * A function with the shape of the global `fetch`, taking a URL and an * options object with `method`, `headers` and `body`, and resolving to a @@ -861,6 +905,7 @@ module.exports = { ContextResult, SignatureResult, FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, diff --git a/fiftyone.pipeline.did/examples/fodIdExample.js b/fiftyone.pipeline.did/examples/fodIdExample.js index 3a87b33..300bc90 100644 --- a/fiftyone.pipeline.did/examples/fodIdExample.js +++ b/fiftyone.pipeline.did/examples/fodIdExample.js @@ -113,7 +113,7 @@ async function run () { console.log(' Domain :', fodId.domain); console.log(' Type :', IdType.name(fodId.type)); console.log(' Usage :', Usage.name(fodId.usage)); - console.log(' From cons.:', fodId.usageFromConsent); + console.log(' Indirect :', fodId.usageIsIndirect); console.log(' LicenseId :', fodId.licenseId); console.log(' Match key :', Buffer.from(fodId.matchKey).toString('hex')); console.log(' Terms :', fodId.terms); diff --git a/fiftyone.pipeline.did/fodId.js b/fiftyone.pipeline.did/fodId.js index 7b7e795..b44271b 100644 --- a/fiftyone.pipeline.did/fodId.js +++ b/fiftyone.pipeline.did/fodId.js @@ -30,7 +30,7 @@ const FodIdParseError = require('./fodIdParseError'); /** * Why a read of a 51Did succeeded or failed. The OWID library's own * vocabulary is carried through unchanged, because a 51Did failing to be - * an OWID is reported exactly as the OWID library reported it, and two + * an OWID is reported exactly as the OWID library reported it, and four * members are added for the outcomes that belong to the 51Did payload * rather than to the envelope. Frozen, and compared by value rather than * by the text of any message. @@ -55,7 +55,13 @@ const ParseStatus = Object.freeze(Object.assign({}, owid.ParseStatus, { * layout this package knows would answer with values that are wrong * rather than absent. */ - UNSUPPORTED_PAYLOAD_VERSION: 'UnsupportedPayloadVersion' + UNSUPPORTED_PAYLOAD_VERSION: 'UnsupportedPayloadVersion', + /** + * Bits 0 to 2 of the flags byte are all clear, which is not a usage. The + * cloud writes no flags byte without bit 0, so such a payload is damaged + * or forged, and it is refused rather than offered as a fourth usage. + */ + NO_USAGE: 'NoUsage' })); /** @@ -106,7 +112,8 @@ const ParseStatus = Object.freeze(Object.assign({}, owid.ParseStatus, { class FodId { /** * Why a read succeeded or failed, being the OWID library's statuses plus - * `PAYLOAD_TOO_SHORT` and `INVALID_TYPE_PAYLOAD_LENGTH`. Frozen. + * `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH`, + * `UNSUPPORTED_PAYLOAD_VERSION` and `NO_USAGE`. Frozen. * @type {Readonly>} */ static ParseStatus = ParseStatus; @@ -306,13 +313,16 @@ class FodId { } /** - * Whether the usage was derived from an IAB consent string the caller - * sent, rather than stated by the caller directly. Bit 3 of the flags. - * Both are legitimate ways to arrive at a usage, and this says nothing - * about which usage it is. + * Whether the usage is indirect, being worked out by the issuer from a + * signal other than the caller stating it. Bit 3 of the flags. False + * means the caller stated the usage directly. A consent string is the + * only indirect signal today, so today this is true only where the usage + * was derived from one, but a later signal of another kind sets the same + * bit. Both are legitimate ways to arrive at a usage, and this says + * nothing about which usage it is. * @returns {boolean} */ - get usageFromConsent () { + get usageIsIndirect () { return (this._flags & 0b1000) !== 0; } @@ -462,10 +472,10 @@ class FodId { * @param {Uint8Array} payload the payload bytes * @returns {{status: string, flags?: number, licenseId?: number, * matchKey?: Uint8Array, termsIndex?: number, length: number, - * required: number, type?: number, payloadVersion?: number}} `status` - * PARSED with the fields, or a 51Did status with the length the type - * needed, and the version found where that is what the payload was refused - * for + * required: number, type?: number, payloadVersion?: number, + * usageBits?: number}} `status` PARSED with the fields, or a 51Did status + * with the length the type needed, and the version or the usage bits found + * where that is what the payload was refused for */ function unpack (payload) { const length = payload.length; @@ -491,6 +501,19 @@ function unpack (payload) { payloadVersion }; } + // Usage bits 000 are not a usage. The cloud writes no flags byte without + // bit 0, so a payload carrying them is damaged or forged, and there is + // nothing a caller could do with a fourth usage that a refusal does not + // already say, being that the identifier must not be passed on. + const usageBits = flags & 0b111; + if (usageBits === 0) { + return { + status: ParseStatus.NO_USAGE, + length, + required: layout.HEADER_LENGTH, + usageBits + }; + } // Little-endian unsigned 32-bit. `>>> 0` forces unsigned so the high bit // does not produce a negative number. const licenseId = ( @@ -616,7 +639,7 @@ function valueOrThrow (read) { } /** - * The exception for a failed read. The three 51Did payload statuses keep the + * The exception for a failed read. The four 51Did payload statuses keep the * RangeError this package has always thrown for them, and every OWID status * is a FodIdParseError carrying the status. Each error carries `status` so * the reason can be acted on without reading the message. @@ -638,6 +661,11 @@ function errorFor (read) { error = new RangeError( `51Did payload version ${read.detail.payloadVersion} is not one this ` + 'package can read.'); + } else if (read.status === ParseStatus.NO_USAGE) { + error = new RangeError( + '51Did payload carries usage bits ' + + read.detail.usageBits.toString(2).padStart(3, '0') + + ', which is not a usage.'); } else { return new FodIdParseError(read.status); } diff --git a/fiftyone.pipeline.did/fodIdParseError.js b/fiftyone.pipeline.did/fodIdParseError.js index 6747f7d..1727eaa 100644 --- a/fiftyone.pipeline.did/fodIdParseError.js +++ b/fiftyone.pipeline.did/fodIdParseError.js @@ -26,7 +26,7 @@ * constructor) when the OWID library refused the envelope. The status names * the reason in the same vocabulary the non-throwing surfaces report, so a * caller catching this can act on the reason without reading the message. - * The three 51Did payload statuses are thrown as RangeError instead, as this + * The four 51Did payload statuses are thrown as RangeError instead, as this * package has always thrown them, and that RangeError carries `status` too. */ class FodIdParseError extends Error { diff --git a/fiftyone.pipeline.did/index.js b/fiftyone.pipeline.did/index.js index 79c565b..d5af75b 100644 --- a/fiftyone.pipeline.did/index.js +++ b/fiftyone.pipeline.did/index.js @@ -30,6 +30,7 @@ const { ContextResult, SignatureResult, FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, @@ -46,6 +47,7 @@ module.exports = { ContextResult, SignatureResult, FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, diff --git a/fiftyone.pipeline.did/readme.md b/fiftyone.pipeline.did/readme.md index 8c60b8a..82a9a63 100644 --- a/fiftyone.pipeline.did/readme.md +++ b/fiftyone.pipeline.did/readme.md @@ -88,7 +88,6 @@ read before the identifier is passed anywhere. | `Usage` | The cloud's `id.usage` | Meaning | | --- | --- | --- | -| `NONE` | none | No usage bit is set. The cloud never issues such an identifier, so treat it as one that may not be passed on | | `NON_MARKETING` | `non-marketing` | Created for use that is not marketing. Must never be passed to a demand source | | `STANDARD` | `standard` | Created for standard marketing, being targeting unrelated to browsing history | | `PERSONALIZED` | `personalized` | Created for personalized marketing, being targeting related to browsing history | @@ -101,14 +100,25 @@ non-marketing, which is the wrong way round for a rule that turns on it. `fodId.usage` answers with the highest usage granted, so that mistake cannot be made, and it is the only supported way to read the usage. -`fodId.usageFromConsent` says whether the usage was worked out from an IAB -consent string the caller sent rather than stated by the caller directly. Both -are legitimate ways to arrive at a usage, and it says nothing about which -usage it is. +There are exactly these three values. A payload whose usage bits are all +clear is not given a fourth value, and is refused with +`ParseStatus.NO_USAGE` instead, because the cloud never writes such a flags +byte and the only safe answer to one is not to pass the identifier on, which +the refusal already gives. + +`fodId.usageIsIndirect` says whether the usage is indirect, being worked out +by the issuer from a signal other than the caller stating it, or direct, +being stated by the caller. A consent string is the only indirect signal +today, so today it is true only where the usage was derived from an IAB +consent string the caller sent. Both are legitimate ways to arrive at a +usage, and it says nothing about which usage it is. It was called +`usageFromConsent` before the field was restated as direct against +indirect, and the old name has been removed. `Usage.name(usage)` gives the cross language name, for example `"NonMarketing"`, and `Usage.idUsage(usage)` gives the cloud's own `id.usage` -value, for example `"non-marketing"`, or `null` for `NONE`. +value, for example `"non-marketing"`. Both answer `null` for a value that +is not a `Usage`. ## The terms a 51Did was created under @@ -217,6 +227,7 @@ OWID library's status, so a specific reason is never reduced to a general one. | `PAYLOAD_TOO_SHORT` | 51Did | The payload is shorter than the 5 byte header (flags and licence id), so the type cannot be read | | `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 | | `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 | +| `NO_USAGE` | 51Did | Bits 0 to 2 of the flags byte are all clear, which is not a usage. The cloud never writes such a flags byte, so the identifier is damaged or forged | A Reserved type is not yet assigned, so the reader accepts it at any length from the header up and exposes whatever follows the header as the match key. @@ -230,7 +241,7 @@ exception. They run the same checks, in the same order, and throw: | Thrown | When | | --- | --- | | `TypeError` | The argument is the wrong kind of thing, being `null`, `undefined`, a non-string to `fromBase64`, or a non-`Uint8Array` to `fromByteArray` | -| `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` | +| `RangeError` | The payload is `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH`, `UNSUPPORTED_PAYLOAD_VERSION` or `NO_USAGE`, being the four statuses the 51Did payload rules produce. The error carries `status` | | `FodIdParseError` | The OWID library refused the envelope for any other status. The error carries `status` | A wrong argument type is a programming error and stays exceptional on every @@ -263,7 +274,7 @@ and `PAYLOAD_LENGTH` are no longer part of the package. | Before | After | | --- | --- | -| `fodId.flags` masked for a usage bit | `fodId.usage`, and `fodId.usageFromConsent` for bit 3 | +| `fodId.flags` masked for a usage bit | `fodId.usage`, and `fodId.usageIsIndirect` for bit 3 | | `fodId.flags` masked for the type bits | `fodId.type` | | `fodId.hash` | `fodId.matchKey`, the same bytes under the name the Model Terms for Marketing use | | `fodId.dateMinutes` | `fodId.date`, which reports the same unsigned value | @@ -317,7 +328,7 @@ const { FodId, IdType, Usage, Terms } = require('fiftyone.pipeline.did'); const fodId = FodId.fromBase64(base64FromCloudService); const usage = fodId.usage; // Usage.NON_MARKETING / STANDARD / PERSONALIZED -const fromConsent = fodId.usageFromConsent; +const indirect = fodId.usageIsIndirect; const type = fodId.type; // IdType.PROBABILISTIC / RANDOM / HASHED_EMAIL const licenseId = fodId.licenseId; const matchKey = fodId.matchKey; // Uint8Array: SHA-256 or GUID bytes, see type @@ -469,9 +480,12 @@ redeemed.context // ContextResult: 'verified', 'mismatch', 'nocontext', // 'notcheckable', 'expired', 'replayed', 'unreadable', // 'unconfirmed' redeemed.signature // SignatureResult: 'verified', 'invalid' or 'unknown' -redeemed.factors // only on a mismatch: { transport, device, browserip, - // connectionip, asn, browser } each 'verified', - // 'mismatch' or null where nothing was compared +redeemed.factors // where there is something to diagnose: + // { transport, device, browserip, connectionip, + // asn, platformname, platformversion, + // browsername, browserversion } each 'verified', + // 'mismatch', 'misconfigured', or null where + // nothing was compared redeemed.verifiedAt // Date, on the redeemed and expired outcomes redeemed.secondsSinceVerified redeemed.statusCode // 200, or 503 for 'unconfirmed', which may be retried @@ -479,6 +493,15 @@ redeemed.raw // the body as received redeemed.toJSON() // the cloud's own response shape, for relaying to a page ``` +The factor names are in `Factor`, in the order the cloud lists them. From +cloud release 4.4.38 the operating system and the browser each have a name +and a version, replacing the single `browser` factor, so a version mismatch +beside a verified name reads as an upgrade and a mismatched name reads as a +different operating system or browser. `factors` carries only the names in +`Factor`, so the old `browser` key is not read into it. A factor that is +`misconfigured` was not checked by the service, and must never be read as a +mismatch. + A context string this package does not know maps to `unreadable`, so an unrecognised outcome is never mistaken for a good one, and `contextRaw` keeps the string as sent. Every cryptographic failure comes back from the cloud as diff --git a/fiftyone.pipeline.did/tests/didClient.integration.test.js b/fiftyone.pipeline.did/tests/didClient.integration.test.js index 65bc31b..1191e38 100644 --- a/fiftyone.pipeline.did/tests/didClient.integration.test.js +++ b/fiftyone.pipeline.did/tests/didClient.integration.test.js @@ -150,10 +150,10 @@ live('DidClient against the cloud', () => { // non-marketing bit reads every marketing identifier as non-marketing. // The label travels into the expectation so a failure names which // identifier it was. - const assertAligned = (label, id, usage, terms, fromConsent) => { + const assertAligned = (label, id, usage, terms, indirect) => { expect({ label, usage: id.usage }).toEqual({ label, usage }); - expect({ label, fromConsent: id.usageFromConsent }) - .toEqual({ label, fromConsent }); + expect({ label, indirect: id.usageIsIndirect }) + .toEqual({ label, indirect }); expect({ label, terms: id.terms }).toEqual({ label, terms }); expect({ label, type: id.type }) .toEqual({ label, type: IdType.PROBABILISTIC }); @@ -190,7 +190,7 @@ live('DidClient against the cloud', () => { } }); - test('a consent string sets the usage-from-consent bit', async () => { + test('a consent string sets the usage is indirect bit', async () => { let proven = 0; for (const [tcString, usage] of consentStrings) { // No id.usage is sent. A stated usage wins over a consent string, so @@ -212,10 +212,10 @@ live('DidClient against the cloud', () => { } if (proven === 0) { console.warn('NOTHING PROVEN: this resource key returned no ' + - 'identifier for either consent string, so the usage-from-consent ' + + 'identifier for either consent string, so the usage is indirect ' + 'bit was never read.'); } else { - console.log(`Usage-from-consent read on ${proven} identifier(s).`); + console.log(`Usage is indirect read on ${proven} identifier(s).`); } }); }); diff --git a/fiftyone.pipeline.did/tests/didClient.test.js b/fiftyone.pipeline.did/tests/didClient.test.js index 94d0fd3..2fcc33c 100644 --- a/fiftyone.pipeline.did/tests/didClient.test.js +++ b/fiftyone.pipeline.did/tests/didClient.test.js @@ -26,6 +26,8 @@ const { RedeemResult, ContextResult, SignatureResult, + FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, @@ -430,7 +432,7 @@ describe('DidClient verifySignature', () => { // A Reserved type parses at any length from the header up, so it is // the way to present a payload the cloud's length rule refuses. const short = new Uint8Array(20); - short[layout.FLAGS_OFFSET] = 0b11000000; + short[layout.FLAGS_OFFSET] = 0b11000001; const fod = await signedAt(pairs[1], new Date(START_2.getTime() + DAY), { payload: short }); await expect(client.verifySignatureDetailed(fod)).resolves.toEqual({ valid: false, reason: SignatureReason.LENGTH @@ -723,7 +725,10 @@ describe('DidClient redeem', () => { browserip: 'verified', connectionip: 'mismatch', asn: 'verified', - browser: null + platformname: 'verified', + platformversion: 'mismatch', + browsername: 'verified', + browserversion: null }, verifiedAt: '2026-08-07T09:15:32Z', secondsSinceVerified: 2 @@ -743,6 +748,70 @@ describe('DidClient redeem', () => { expect(result.toJSON()).toEqual(body); }); + test('the four platform and browser factors are read into their names', + async () => { + const body = { + signature: 'verified', + context: 'mismatch', + factors: { + transport: 'verified', + device: 'verified', + browserip: 'verified', + connectionip: 'verified', + asn: 'verified', + platformname: 'verified', + platformversion: 'mismatch', + browsername: 'mismatch', + browserversion: 'misconfigured' + } + }; + const { client } = redeemClient(200, body); + const result = await client.redeem(fod, RESULT, CHALLENGE); + expect(Object.keys(result.factors)).toEqual(Object.values(Factor)); + expect(result.factors[Factor.PLATFORM_NAME]) + .toBe(FactorResult.VERIFIED); + expect(result.factors[Factor.PLATFORM_VERSION]) + .toBe(FactorResult.MISMATCH); + expect(result.factors[Factor.BROWSER_NAME]) + .toBe(FactorResult.MISMATCH); + expect(result.factors[Factor.BROWSER_VERSION]) + .toBe(FactorResult.MISCONFIGURED); + }); + + test('the factor names are the nine the cloud lists, in its order', () => { + expect(Object.isFrozen(Factor)).toBe(true); + expect(Object.values(Factor)).toEqual([ + 'transport', 'device', 'browserip', 'connectionip', 'asn', + 'platformname', 'platformversion', 'browsername', 'browserversion' + ]); + }); + + test('an old browser factor populates none of the four', async () => { + const body = { + signature: 'verified', + context: 'mismatch', + factors: { + transport: 'verified', + device: 'verified', + browserip: 'verified', + connectionip: 'verified', + asn: 'verified', + browser: 'mismatch' + } + }; + const { client } = redeemClient(200, body); + const result = await client.redeem(fod, RESULT, CHALLENGE); + expect(result.factors).toBeDefined(); + for (const name of [Factor.PLATFORM_NAME, Factor.PLATFORM_VERSION, + Factor.BROWSER_NAME, Factor.BROWSER_VERSION]) { + expect(result.factors[name]).toBeUndefined(); + } + expect('browser' in result.factors).toBe(false); + expect(result.factors[Factor.TRANSPORT]).toBe(FactorResult.VERIFIED); + // The body as sent still carries it, for a caller who needs to see it. + expect(JSON.parse(result.raw).factors.browser).toBe('mismatch'); + }); + test('redeemed without factors (verified)', async () => { const body = { signature: 'verified', diff --git a/fiftyone.pipeline.did/tests/fodId.test.js b/fiftyone.pipeline.did/tests/fodId.test.js index e6e21a2..c84998c 100644 --- a/fiftyone.pipeline.did/tests/fodId.test.js +++ b/fiftyone.pipeline.did/tests/fodId.test.js @@ -143,10 +143,12 @@ describe('FodId', () => { expect(FodId.fromBase64(envelopeBase64(p)).licenseId).toBe(0x80000000); }); - test('a flags byte of zero is read unchanged', () => { + test('the least flags byte a reader accepts is read unchanged', () => { + // Bit 0 is the least a flags byte can carry, because usage bits 000 + // are refused, which the usage tests cover. const p = canonicalPayload(); - p[layout.FLAGS_OFFSET] = 0x00; - expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(0); + p[layout.FLAGS_OFFSET] = 0x01; + expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(1); }); test('every flags bit outside the version is read unchanged', () => { @@ -279,7 +281,6 @@ describe('FodId', () => { // mask for the non-marketing bit alone would say yes for every marketing // identifier, which is the wrong answer for a data protection decision. test.each([ - [0b000, Usage.NONE, null], [0b001, Usage.NON_MARKETING, 'non-marketing'], [0b011, Usage.STANDARD, 'standard'], [0b111, Usage.PERSONALIZED, 'personalized'] @@ -290,17 +291,81 @@ describe('FodId', () => { expect(fod.usage).toBe(expected); expect(Usage.idUsage(fod.usage)).toBe(idUsage); expect(fod.type).toBe(IdType.RANDOM); - expect(fod.usageFromConsent).toBe(false); + expect(fod.usageIsIndirect).toBe(false); }); - test('usage from consent is bit three', () => { + test('usage is indirect is bit three', () => { const p = canonicalRandomPayload(); p[layout.FLAGS_OFFSET] = (1 << 6) | 0b1011; const fod = FodId.fromBase64(envelopeBase64(p)); - expect(fod.usageFromConsent).toBe(true); + expect(fod.usageIsIndirect).toBe(true); expect(fod.usage).toBe(Usage.STANDARD); }); + test.each([ + [0b0001, false], + [0b1001, true], + [0b0011, false], + [0b1011, true], + [0b0111, false], + [0b1111, true] + ])('flags %s answer usage is indirect %s, and only from bit three', + (bits, indirect) => { + const p = canonicalPayload(); + p[layout.FLAGS_OFFSET] = bits; + const fod = FodId.fromBase64(envelopeBase64(p)); + expect(fod.usageIsIndirect).toBe(indirect); + expect(fod.usage).toBe(Usage.fromFlags(bits & 0b111)); + }); + + test('the old usage from consent name is gone, with no alias', () => { + const fod = FodId.fromBase64(envelopeBase64(canonicalPayload())); + expect('usageFromConsent' in fod).toBe(false); + }); + + // Usage bits 000 are not a usage. The cloud never writes them, so the + // payload is refused the way an unknown payload version is, and never + // offered as a fourth usage. + test('Usage has exactly the three usages', () => { + expect(Object.keys(Usage).filter((k) => typeof Usage[k] === 'number')) + .toEqual(['NON_MARKETING', 'STANDARD', 'PERSONALIZED']); + expect('NONE' in Usage).toBe(false); + expect(() => Usage.fromFlags(0b1000)).toThrow(RangeError); + expect(Usage.name(0)).toBeNull(); + expect(Usage.idUsage(0)).toBeNull(); + }); + + test.each([ + ['Probabilistic', 0b00, () => canonicalPayload()], + ['Random', 0b01, () => canonicalRandomPayload()], + ['HashedEmail', 0b10, () => canonicalPayload()], + ['Reserved', 0b11, () => canonicalPayload()] + ])('usage bits 000 are refused for the %s type, and the refusal names them', + (name, type, payload) => { + for (const bit3 of [0, 0b1000]) { + const p = payload(); + p[layout.FLAGS_OFFSET] = (type << 6) | bit3; + const encoded = envelopeBase64(p); + + const read = FodId.tryParse(encoded); + expect(read.ok).toBe(false); + expect(read.value).toBeNull(); + expect(read.status).toBe(ParseStatus.NO_USAGE); + expect(FodId.tryFromByteArray(envelopeBytes(p)).status) + .toBe(ParseStatus.NO_USAGE); + + let thrown; + try { + FodId.fromBase64(encoded); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(RangeError); + expect(thrown.status).toBe(ParseStatus.NO_USAGE); + expect(thrown.message).toContain('usage bits 000'); + } + }); + test('type is Random when bits are 01', () => { const fod = FodId.fromBase64(envelopeBase64(canonicalRandomPayload())); expect(fod.type).toBe(IdType.RANDOM); @@ -336,7 +401,7 @@ describe('FodId', () => { test('Reserved header-only payload parses', () => { const p = new Uint8Array(layout.MATCH_KEY_OFFSET); - p[layout.FLAGS_OFFSET] = 0b1100_0000; + p[layout.FLAGS_OFFSET] = 0b1100_0001; const fod = FodId.fromBase64(envelopeBase64(p)); expect(fod.type).toBe(IdType.RESERVED); expect(fod.matchKey.length).toBe(0); @@ -456,7 +521,7 @@ describe('FodId', () => { // A Reserved type is not yet assigned, so everything after the header // is exposed as the match key and no byte is left to read as the terms. const p = new Uint8Array(layout.MATCH_KEY_OFFSET); - p[layout.FLAGS_OFFSET] = 0b1100_0000; + p[layout.FLAGS_OFFSET] = 0b1100_0001; const fod = FodId.fromBase64(envelopeBase64(p)); expect(fod.type).toBe(IdType.RESERVED); expect(fod.terms).toBeNull(); @@ -569,12 +634,19 @@ describe('FodId', () => { test('the version is read apart from the usage and type bits', () => { // A reader masking the wrong bits would refuse a version 0 identifier // or let a later version through, so every combination is tried. + // Usage bits 000 are refused as no usage under version 0, and the + // version is read first, so a later version is still reported as such. for (const usage of [0b000, 0b001, 0b011, 0b111]) { for (const type of [0b00, 0b10, 0b11]) { const p = payloadEndingAtMatchKey(); p[layout.FLAGS_OFFSET] = (type << 6) | usage; - expect(FodId.tryParse(envelopeBase64(p)).ok).toBe(true); + const read = FodId.tryParse(envelopeBase64(p)); + if (usage === 0b000) { + expect(read.status).toBe(ParseStatus.NO_USAGE); + } else { + expect(read.ok).toBe(true); + } for (const version of [1, 2, 3]) { const refused = FodId.tryParse( @@ -814,7 +886,7 @@ describe('FodId.tryParse and tryFromByteArray', () => { } } - test('the status vocabulary is the OWID one plus the three 51Did members', () => { + test('the status vocabulary is the OWID one plus the four 51Did members', () => { expect(Object.isFrozen(ParseStatus)).toBe(true); for (const [name, value] of Object.entries(owid.ParseStatus)) { expect(ParseStatus[name]).toBe(value); @@ -824,8 +896,9 @@ describe('FodId.tryParse and tryFromByteArray', () => { .toBe('InvalidTypePayloadLength'); expect(ParseStatus.UNSUPPORTED_PAYLOAD_VERSION) .toBe('UnsupportedPayloadVersion'); + expect(ParseStatus.NO_USAGE).toBe('NoUsage'); expect(Object.keys(ParseStatus)) - .toHaveLength(Object.keys(owid.ParseStatus).length + 3); + .toHaveLength(Object.keys(owid.ParseStatus).length + 4); expect(SignatureStatus).toBe(owid.SignatureStatus); }); @@ -915,7 +988,7 @@ describe('FodId.tryParse and tryFromByteArray', () => { test('a Reserved payload keeps the best-effort read at any length from the header up', () => { for (const length of [layout.HEADER_LENGTH, 12, layout.PAYLOAD_LENGTH + 100]) { const p = new Uint8Array(length); - p[layout.FLAGS_OFFSET] = 0b1100_0000; + p[layout.FLAGS_OFFSET] = 0b1100_0001; const fod = expectParsed(FodId.tryParse(envelopeBase64(p))); expect(fod.type).toBe(IdType.RESERVED); expect(fod.matchKey).toHaveLength(length - layout.HEADER_LENGTH); diff --git a/fiftyone.pipeline.did/types/didClient.d.ts b/fiftyone.pipeline.did/types/didClient.d.ts index 32db748..1b5b614 100644 --- a/fiftyone.pipeline.did/types/didClient.d.ts +++ b/fiftyone.pipeline.did/types/didClient.d.ts @@ -305,10 +305,14 @@ export class RedeemResult { /** @type {string} one of {@link SignatureResult} */ signature: string; /** - * @type {object | undefined} factor name to {@link FactorResult} value - * (or null where nothing was compared), present only when the cloud - * sent `factors`, which is the mismatch outcome. The names are - * transport, device, browserip, connectionip, asn and browser. + * @type {object | undefined} {@link Factor} name to + * {@link FactorResult} value (or null where nothing was compared), + * present only when the cloud sent `factors`, which it does where there + * is something to diagnose, being a mismatch or a misconfigured result + * that still compared some factors. Only the names in {@link Factor} + * are carried, in that order, so a name this package does not know, + * such as the `browser` factor the four platform and browser factors + * replaced, is not read into it. `raw` still holds the body as sent. */ factors: object | undefined; /** @@ -395,6 +399,25 @@ export const FactorResult: Readonly<{ */ MISCONFIGURED: "misconfigured"; }>; +/** + * The names of the creator context factors, as the cloud writes them as + * keys of `factors`, in the order the cloud lists them. The operating + * system and the browser each have a name and a version, so a version + * mismatch beside a verified name reads as an upgrade, and a mismatched + * name reads as a different operating system or browser. These four + * replaced the single `browser` factor from cloud release 4.4.38. + */ +export const Factor: Readonly<{ + TRANSPORT: "transport"; + DEVICE: "device"; + BROWSER_IP: "browserip"; + CONNECTION_IP: "connectionip"; + ASN: "asn"; + PLATFORM_NAME: "platformname"; + PLATFORM_VERSION: "platformversion"; + BROWSER_NAME: "browsername"; + BROWSER_VERSION: "browserversion"; +}>; /** * The reason a {@link DidClient#verifySignatureDetailed} answer was given. */ diff --git a/fiftyone.pipeline.did/types/fodId.d.ts b/fiftyone.pipeline.did/types/fodId.d.ts index da2810f..c9465a9 100644 --- a/fiftyone.pipeline.did/types/fodId.d.ts +++ b/fiftyone.pipeline.did/types/fodId.d.ts @@ -46,7 +46,8 @@ export = FodId; declare class FodId { /** * Why a read succeeded or failed, being the OWID library's statuses plus - * `PAYLOAD_TOO_SHORT` and `INVALID_TYPE_PAYLOAD_LENGTH`. Frozen. + * `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH`, + * `UNSUPPORTED_PAYLOAD_VERSION` and `NO_USAGE`. Frozen. * @type {Readonly>} */ static ParseStatus: Readonly>; @@ -183,13 +184,16 @@ declare class FodId { */ get usage(): number; /** - * Whether the usage was derived from an IAB consent string the caller - * sent, rather than stated by the caller directly. Bit 3 of the flags. - * Both are legitimate ways to arrive at a usage, and this says nothing - * about which usage it is. + * Whether the usage is indirect, being worked out by the issuer from a + * signal other than the caller stating it. Bit 3 of the flags. False + * means the caller stated the usage directly. A consent string is the + * only indirect signal today, so today this is true only where the usage + * was derived from one, but a later signal of another kind sets the same + * bit. Both are legitimate ways to arrive at a usage, and this says + * nothing about which usage it is. * @returns {boolean} */ - get usageFromConsent(): boolean; + get usageIsIndirect(): boolean; /** * The 4-byte little-endian field at offset 1 of the payload, as an * unsigned integer (0-4294967295). diff --git a/fiftyone.pipeline.did/types/fodIdParseError.d.ts b/fiftyone.pipeline.did/types/fodIdParseError.d.ts index 6e91b06..e54c1ab 100644 --- a/fiftyone.pipeline.did/types/fodIdParseError.d.ts +++ b/fiftyone.pipeline.did/types/fodIdParseError.d.ts @@ -5,7 +5,7 @@ export = FodIdParseError; * constructor) when the OWID library refused the envelope. The status names * the reason in the same vocabulary the non-throwing surfaces report, so a * caller catching this can act on the reason without reading the message. - * The three 51Did payload statuses are thrown as RangeError instead, as this + * The four 51Did payload statuses are thrown as RangeError instead, as this * package has always thrown them, and that RangeError carries `status` too. */ declare class FodIdParseError extends Error { diff --git a/fiftyone.pipeline.did/types/index.d.ts b/fiftyone.pipeline.did/types/index.d.ts index 4cd1b2f..566324f 100644 --- a/fiftyone.pipeline.did/types/index.d.ts +++ b/fiftyone.pipeline.did/types/index.d.ts @@ -7,8 +7,9 @@ import { RedeemResult } from "./didClient"; import { ContextResult } from "./didClient"; import { SignatureResult } from "./didClient"; import { FactorResult } from "./didClient"; +import { Factor } from "./didClient"; import { SignatureReason } from "./didClient"; import { DidClientError } from "./didClient"; import { DidArgumentError } from "./didClient"; import { DidNotSupportedError } from "./didClient"; -export { FodId, FodIdParseError, IdType, Usage, DidClient, RedeemResult, ContextResult, SignatureResult, FactorResult, SignatureReason, DidClientError, DidArgumentError, DidNotSupportedError }; +export { FodId, FodIdParseError, IdType, Usage, DidClient, RedeemResult, ContextResult, SignatureResult, FactorResult, Factor, SignatureReason, DidClientError, DidArgumentError, DidNotSupportedError }; diff --git a/fiftyone.pipeline.did/types/usage.d.ts b/fiftyone.pipeline.did/types/usage.d.ts index 1c1160d..47021fe 100644 --- a/fiftyone.pipeline.did/types/usage.d.ts +++ b/fiftyone.pipeline.did/types/usage.d.ts @@ -1,11 +1,5 @@ export = Usage; declare const Usage: Readonly<{ - /** - * No usage bit is set. The cloud never issues such an identifier, so - * this is an identifier from somewhere else or a damaged one, and it - * should be treated as though it may not be passed on. - */ - NONE: 0; /** Created for use that is not marketing. Must not be passed to a demand source. */ NON_MARKETING: 1; /** Created for standard marketing, being targeting unrelated to browsing history. */ @@ -14,21 +8,27 @@ declare const Usage: Readonly<{ PERSONALIZED: 3; /** * Decodes the usage from bits 0-2 of a flags byte, as the highest usage - * granted. + * granted. There is no Usage for bits 000, because the cloud never + * writes a flags byte without bit 0, so a payload carrying 000 is + * damaged or forged and FodId refuses it with + * `FodId.ParseStatus.NO_USAGE` before this is asked. * @param {number} flags the 1-byte flags value (0-255) * @returns {number} the Usage value + * @throws {RangeError} when bits 0-2 are all clear */ fromFlags(flags: number): number; /** * The cross language name of a Usage value. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string} for example "NonMarketing" + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "NonMarketing", or null for a value + * that is not a Usage */ - name(usage: number): string; + name(usage: number): string | null; /** - * The cloud's id.usage value for a Usage value, or null for NONE. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string|null} for example "non-marketing" + * The cloud's id.usage value for a Usage value. + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "non-marketing", or null for a value + * that is not a Usage */ idUsage(usage: number): string | null; }>; diff --git a/fiftyone.pipeline.did/usage.js b/fiftyone.pipeline.did/usage.js index fdd2331..4cbabff 100644 --- a/fiftyone.pipeline.did/usage.js +++ b/fiftyone.pipeline.did/usage.js @@ -42,15 +42,9 @@ * https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md * which is the authority rather than this comment. */ -const NAMES = ['None', 'NonMarketing', 'Standard', 'Personalized']; +const NAMES = [null, 'NonMarketing', 'Standard', 'Personalized']; const ID_USAGE = [null, 'non-marketing', 'standard', 'personalized']; const Usage = Object.freeze({ - /** - * No usage bit is set. The cloud never issues such an identifier, so - * this is an identifier from somewhere else or a damaged one, and it - * should be treated as though it may not be passed on. - */ - NONE: 0, /** Created for use that is not marketing. Must not be passed to a demand source. */ NON_MARKETING: 1, /** Created for standard marketing, being targeting unrelated to browsing history. */ @@ -59,31 +53,38 @@ const Usage = Object.freeze({ PERSONALIZED: 3, /** * Decodes the usage from bits 0-2 of a flags byte, as the highest usage - * granted. + * granted. There is no Usage for bits 000, because the cloud never + * writes a flags byte without bit 0, so a payload carrying 000 is + * damaged or forged and FodId refuses it with + * `FodId.ParseStatus.NO_USAGE` before this is asked. * @param {number} flags the 1-byte flags value (0-255) * @returns {number} the Usage value + * @throws {RangeError} when bits 0-2 are all clear */ fromFlags (flags) { if (flags & 0b100) return 3; if (flags & 0b010) return 2; if (flags & 0b001) return 1; - return 0; + throw new RangeError( + 'Usage bits 000 are not a usage, so the flags byte cannot be read.'); }, /** * The cross language name of a Usage value. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string} for example "NonMarketing" + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "NonMarketing", or null for a value + * that is not a Usage */ name (usage) { - return NAMES[usage]; + return NAMES[usage] || null; }, /** - * The cloud's id.usage value for a Usage value, or null for NONE. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string|null} for example "non-marketing" + * The cloud's id.usage value for a Usage value. + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "non-marketing", or null for a value + * that is not a Usage */ idUsage (usage) { - return ID_USAGE[usage]; + return ID_USAGE[usage] || null; } }); module.exports = Usage; From 69f397ffe759dd46573ca92555ef5eb063b86a07 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 16 Sep 2026 21:02:56 +0100 Subject: [PATCH 2/4] FIX: Keep every creator context factor name the cloud sends RedeemResult.factors and toJSON pass every factor name through unchanged again, as the other 51Did packages do, so a factor the cloud adds later reaches callers without a package release and an older service's browser key stays under its own name. The Factor constant still lists the nine names in the cloud's order. --- fiftyone.pipeline.did/didClient.js | 36 +++------- fiftyone.pipeline.did/readme.md | 10 +-- fiftyone.pipeline.did/tests/didClient.test.js | 68 ++++++++++++------- fiftyone.pipeline.did/types/didClient.d.ts | 10 +-- 4 files changed, 64 insertions(+), 60 deletions(-) diff --git a/fiftyone.pipeline.did/didClient.js b/fiftyone.pipeline.did/didClient.js index 3eab916..4b8f9ae 100644 --- a/fiftyone.pipeline.did/didClient.js +++ b/fiftyone.pipeline.did/didClient.js @@ -132,6 +132,8 @@ const FactorResult = Object.freeze({ * mismatch beside a verified name reads as an upgrade, and a mismatched * name reads as a different operating system or browser. These four * replaced the single `browser` factor from cloud release 4.4.38. + * {@link RedeemResult#factors} is not limited to these names, so a factor + * the cloud adds later still reaches the caller. */ const Factor = Object.freeze({ TRANSPORT: 'transport', @@ -145,8 +147,6 @@ const Factor = Object.freeze({ BROWSER_VERSION: 'browserversion' }); -const FACTOR_NAMES = Object.freeze(Object.values(Factor)); - /** * The reason a {@link DidClient#verifySignatureDetailed} answer was given. */ @@ -259,12 +259,14 @@ class RedeemResult { * {@link FactorResult} value (or null where nothing was compared), * present only when the cloud sent `factors`, which it does where there * is something to diagnose, being a mismatch or a misconfigured result - * that still compared some factors. Only the names in {@link Factor} - * are carried, in that order, so a name this package does not know, - * such as the `browser` factor the four platform and browser factors - * replaced, is not read into it. `raw` still holds the body as sent. + * that still compared some factors. Every name is kept exactly as the + * cloud sent it, including one this package does not list in + * {@link Factor}, so a factor the cloud adds later reaches the caller + * without a new release of this package. */ - this.factors = readFactors(parsed.factors); + this.factors = parsed.factors && typeof parsed.factors === 'object' + ? Object.freeze(Object.assign({}, parsed.factors)) + : undefined; const verifiedAt = typeof parsed.verifiedAt === 'string' ? new Date(parsed.verifiedAt) : null; @@ -328,26 +330,6 @@ class RedeemResult { } } -/** - * Reads the factors the cloud reported, keeping only the names in - * {@link Factor} and in that order. - * @param {*} factors the `factors` value of a parsed body - * @returns {object | undefined} a frozen object, or undefined where the - * body carried no factors object - */ -function readFactors (factors) { - if (!factors || typeof factors !== 'object' || Array.isArray(factors)) { - return undefined; - } - const read = {}; - for (const name of FACTOR_NAMES) { - if (Object.prototype.hasOwnProperty.call(factors, name)) { - read[name] = factors[name]; - } - } - return Object.freeze(read); -} - /** * A function with the shape of the global `fetch`, taking a URL and an * options object with `method`, `headers` and `body`, and resolving to a diff --git a/fiftyone.pipeline.did/readme.md b/fiftyone.pipeline.did/readme.md index 82a9a63..8d1e658 100644 --- a/fiftyone.pipeline.did/readme.md +++ b/fiftyone.pipeline.did/readme.md @@ -497,10 +497,12 @@ The factor names are in `Factor`, in the order the cloud lists them. From cloud release 4.4.38 the operating system and the browser each have a name and a version, replacing the single `browser` factor, so a version mismatch beside a verified name reads as an upgrade and a mismatched name reads as a -different operating system or browser. `factors` carries only the names in -`Factor`, so the old `browser` key is not read into it. A factor that is -`misconfigured` was not checked by the service, and must never be read as a -mismatch. +different operating system or browser. `factors` keeps every name exactly as +the cloud sent it, including a name that is not in `Factor`, so a factor the +cloud adds later reaches the caller without a new release of this package, +and an older service's `browser` key stays under its own name rather than +filling any of the four. A factor that is `misconfigured` was not checked by +the service, and must never be read as a mismatch. A context string this package does not know maps to `unreadable`, so an unrecognised outcome is never mistaken for a good one, and `contextRaw` keeps diff --git a/fiftyone.pipeline.did/tests/didClient.test.js b/fiftyone.pipeline.did/tests/didClient.test.js index 2fcc33c..308241f 100644 --- a/fiftyone.pipeline.did/tests/didClient.test.js +++ b/fiftyone.pipeline.did/tests/didClient.test.js @@ -767,7 +767,7 @@ describe('DidClient redeem', () => { }; const { client } = redeemClient(200, body); const result = await client.redeem(fod, RESULT, CHALLENGE); - expect(Object.keys(result.factors)).toEqual(Object.values(Factor)); + expect(result.factors).toEqual(body.factors); expect(result.factors[Factor.PLATFORM_NAME]) .toBe(FactorResult.VERIFIED); expect(result.factors[Factor.PLATFORM_VERSION]) @@ -780,37 +780,55 @@ describe('DidClient redeem', () => { test('the factor names are the nine the cloud lists, in its order', () => { expect(Object.isFrozen(Factor)).toBe(true); + expect(Object.keys(Factor)).toEqual([ + 'TRANSPORT', 'DEVICE', 'BROWSER_IP', 'CONNECTION_IP', 'ASN', + 'PLATFORM_NAME', 'PLATFORM_VERSION', 'BROWSER_NAME', 'BROWSER_VERSION' + ]); expect(Object.values(Factor)).toEqual([ 'transport', 'device', 'browserip', 'connectionip', 'asn', 'platformname', 'platformversion', 'browsername', 'browserversion' ]); }); - test('an old browser factor populates none of the four', async () => { - const body = { - signature: 'verified', - context: 'mismatch', - factors: { - transport: 'verified', - device: 'verified', - browserip: 'verified', - connectionip: 'verified', - asn: 'verified', - browser: 'mismatch' + test('an old browser factor populates none of the four, and is kept', + async () => { + const body = { + signature: 'verified', + context: 'mismatch', + factors: { + transport: 'verified', + device: 'verified', + browserip: 'verified', + connectionip: 'verified', + asn: 'verified', + browser: 'mismatch' + } + }; + const { client } = redeemClient(200, body); + const result = await client.redeem(fod, RESULT, CHALLENGE); + expect(result.factors).toBeDefined(); + for (const name of [Factor.PLATFORM_NAME, Factor.PLATFORM_VERSION, + Factor.BROWSER_NAME, Factor.BROWSER_VERSION]) { + expect(name in result.factors).toBe(false); } - }; - const { client } = redeemClient(200, body); - const result = await client.redeem(fod, RESULT, CHALLENGE); - expect(result.factors).toBeDefined(); - for (const name of [Factor.PLATFORM_NAME, Factor.PLATFORM_VERSION, - Factor.BROWSER_NAME, Factor.BROWSER_VERSION]) { - expect(result.factors[name]).toBeUndefined(); - } - expect('browser' in result.factors).toBe(false); - expect(result.factors[Factor.TRANSPORT]).toBe(FactorResult.VERIFIED); - // The body as sent still carries it, for a caller who needs to see it. - expect(JSON.parse(result.raw).factors.browser).toBe('mismatch'); - }); + // Every name passes through as the cloud sent it, as in every other + // 51Did package. + expect(result.factors.browser).toBe('mismatch'); + expect(result.factors).toEqual(body.factors); + expect(result.toJSON().factors).toEqual(body.factors); + }); + + test('a factor name this package does not list is passed through', + async () => { + const body = { + context: 'mismatch', + factors: { transport: 'verified', laterfactor: 'mismatch' } + }; + const { client } = redeemClient(200, body); + const result = await client.redeem(fod, RESULT, CHALLENGE); + expect(result.factors).toEqual(body.factors); + expect(Object.isFrozen(result.factors)).toBe(true); + }); test('redeemed without factors (verified)', async () => { const body = { diff --git a/fiftyone.pipeline.did/types/didClient.d.ts b/fiftyone.pipeline.did/types/didClient.d.ts index 1b5b614..5cf0b09 100644 --- a/fiftyone.pipeline.did/types/didClient.d.ts +++ b/fiftyone.pipeline.did/types/didClient.d.ts @@ -309,10 +309,10 @@ export class RedeemResult { * {@link FactorResult} value (or null where nothing was compared), * present only when the cloud sent `factors`, which it does where there * is something to diagnose, being a mismatch or a misconfigured result - * that still compared some factors. Only the names in {@link Factor} - * are carried, in that order, so a name this package does not know, - * such as the `browser` factor the four platform and browser factors - * replaced, is not read into it. `raw` still holds the body as sent. + * that still compared some factors. Every name is kept exactly as the + * cloud sent it, including one this package does not list in + * {@link Factor}, so a factor the cloud adds later reaches the caller + * without a new release of this package. */ factors: object | undefined; /** @@ -406,6 +406,8 @@ export const FactorResult: Readonly<{ * mismatch beside a verified name reads as an upgrade, and a mismatched * name reads as a different operating system or browser. These four * replaced the single `browser` factor from cloud release 4.4.38. + * {@link RedeemResult#factors} is not limited to these names, so a factor + * the cloud adds later still reaches the caller. */ export const Factor: Readonly<{ TRANSPORT: "transport"; From 805788350c052c299f6c2111162c90e45b4fd659 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 17 Sep 2026 06:59:46 +0100 Subject: [PATCH 3/4] DOC: Drop Terms from the readme usage example, as the package does not export it The 51Did readme's usage example destructured Terms from the package, which index.js has never exported, so the line as written gives undefined and misleads anyone copying it. The example only ever reads fodId.terms, so nothing else changes. --- fiftyone.pipeline.did/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fiftyone.pipeline.did/readme.md b/fiftyone.pipeline.did/readme.md index 8d1e658..13f8b5d 100644 --- a/fiftyone.pipeline.did/readme.md +++ b/fiftyone.pipeline.did/readme.md @@ -321,7 +321,7 @@ npm test ## Usage ```js -const { FodId, IdType, Usage, Terms } = require('fiftyone.pipeline.did'); +const { FodId, IdType, Usage } = require('fiftyone.pipeline.did'); // Either base64 alphabet is accepted, the standard one the cloud issues and // the URL-safe one a page puts in a link, with or without padding. From 5e6756642b476b6ca6057f0452ab72978ce38e24 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 17 Sep 2026 20:52:25 +0100 Subject: [PATCH 4/4] +semver: minor