fiftyone.pipeline.did 4.5.34 → 4.5.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/didClient.js +849 -0
  2. package/examples/creator-context-web/examples-main.min.css +1 -0
  3. package/examples/creator-context-web/page.html +453 -0
  4. package/examples/creator-context-web/server.js +277 -0
  5. package/examples/fodIdExample.js +31 -7
  6. package/fodId.js +454 -73
  7. package/fodIdParseError.js +45 -0
  8. package/index.js +26 -1
  9. package/node_modules/owid/.github/workflows/ci.yml +23 -0
  10. package/node_modules/owid/LICENSE +201 -0
  11. package/node_modules/owid/README.md +447 -0
  12. package/node_modules/owid/owid.crypto.test.js +359 -0
  13. package/node_modules/owid/owid.frame.test.js +565 -0
  14. package/node_modules/owid/owid.interop.test.js +244 -0
  15. package/node_modules/owid/owid.parse-contract.test.js +720 -0
  16. package/node_modules/owid/owid.payload-length.test.js +485 -0
  17. package/node_modules/owid/owid.status-coverage.test.js +158 -0
  18. package/node_modules/owid/owid.test.js +382 -0
  19. package/node_modules/owid/package.json +22 -0
  20. package/node_modules/owid/setupJest.js +1 -0
  21. package/node_modules/owid/v1.js +1487 -0
  22. package/package.json +9 -4
  23. package/readme.md +459 -31
  24. package/tests/creatorContextServer.test.js +191 -0
  25. package/tests/didClient.integration.test.js +95 -0
  26. package/tests/didClient.test.js +912 -0
  27. package/tests/envelope.js +175 -0
  28. package/tests/fodId.test.js +535 -134
  29. package/tsconfig.json +22 -0
  30. package/types/didClient.d.ts +428 -0
  31. package/types/fodId.d.ts +306 -0
  32. package/types/fodIdParseError.d.ts +19 -0
  33. package/types/idType.d.ts +29 -0
  34. package/types/index.d.ts +13 -0
package/didClient.js ADDED
@@ -0,0 +1,849 @@
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
+ const FodId = require('./fodId');
24
+ const IdType = require('./idType');
25
+ const packageVersion = require('./package.json').version;
26
+
27
+ /**
28
+ * The public cloud API base, used when neither the endpoint option nor the
29
+ * FOD_CLOUD_API_URL environment variable is set.
30
+ */
31
+ const DEFAULT_ENDPOINT = 'https://cloud.51degrees.com/api/v4/';
32
+
33
+ /** Sent with every request so the cloud can tell which package called. */
34
+ const USER_AGENT = 'fiftyone.pipeline.did/' + packageVersion;
35
+
36
+ /** The OWID date field counts minutes from this moment. */
37
+ const OWID_EPOCH_MS = Date.UTC(2020, 0, 1);
38
+ const MINUTE_MS = 60 * 1000;
39
+
40
+ const BOUNDARY_TOLERANCE_MS = 15 * MINUTE_MS;
41
+
42
+ /** A cached key list older than this is fetched again before use. */
43
+ const KEY_LIST_MAX_AGE_MS = 24 * 60 * MINUTE_MS;
44
+
45
+ /** The only envelope version the cloud signs and verifies. */
46
+ const SUPPORTED_VERSION = 3;
47
+
48
+ /**
49
+ * The longest encoded identifier the client will look at. The figure is
50
+ * arbitrary and deliberately generous, far above anything the cloud issues,
51
+ * because its only job is to turn obviously malformed input away before the
52
+ * client decodes it, fetches a key or calls the cloud. It says nothing about
53
+ * how long a 51Did is, and a value under it is still left to the cloud to
54
+ * judge.
55
+ */
56
+ const MAXIMUM_ENCODED_LENGTH = 4096;
57
+
58
+ /**
59
+ * The creator context outcome of a redemption, as the cloud reports it in
60
+ * the `context` field. The values are the cloud's own strings, so a result
61
+ * can be compared to a constant or printed as received.
62
+ */
63
+ const ContextResult = Object.freeze({
64
+ /** Every factor matched the browser and connection that created it. */
65
+ VERIFIED: 'verified',
66
+ /** At least one factor differed. `factors` says which. */
67
+ MISMATCH: 'mismatch',
68
+ /** The identifier carries no creator context. */
69
+ NO_CONTEXT: 'nocontext',
70
+ /** The service holds no secret covering the identifier's date. */
71
+ NOT_CHECKABLE: 'notcheckable',
72
+ /** The sealed result was redeemed outside the freshness window. */
73
+ EXPIRED: 'expired',
74
+ /** The sealed result had already been redeemed on that instance. */
75
+ REPLAYED: 'replayed',
76
+ /**
77
+ * The sealed result could not be read. Every cryptographic failure, a
78
+ * missing licence key included, comes back as this one word by design,
79
+ * and a context string this package does not know is mapped here too.
80
+ */
81
+ UNREADABLE: 'unreadable',
82
+ /** First use could not be confirmed (503). The caller may retry. */
83
+ UNCONFIRMED: 'unconfirmed'
84
+ });
85
+
86
+ const KNOWN_CONTEXTS = new Set(Object.values(ContextResult));
87
+
88
+ /**
89
+ * The signature outcome of a redemption, as the cloud reports it in the
90
+ * `signature` field of a redeemed result. Absent on every other outcome.
91
+ */
92
+ const SignatureResult = Object.freeze({
93
+ VERIFIED: 'verified',
94
+ INVALID: 'invalid',
95
+ /** The cloud did not report the signature, as on an expired result. */
96
+ UNKNOWN: 'unknown'
97
+ });
98
+
99
+ /**
100
+ * The outcome of one factor in a mismatch. The cloud reports `null` for a
101
+ * factor that was not compared, which is passed through unchanged.
102
+ */
103
+ const FactorResult = Object.freeze({
104
+ VERIFIED: 'verified',
105
+ MISMATCH: 'mismatch'
106
+ });
107
+
108
+ /**
109
+ * The reason a {@link DidClient#verifySignatureDetailed} answer was given.
110
+ */
111
+ const SignatureReason = Object.freeze({
112
+ /** A candidate key verified the signature. */
113
+ VERIFIED: 'verified',
114
+ /** The envelope version is not the one the cloud signs. */
115
+ VERSION: 'version',
116
+ /** The payload is shorter than the base length for its type. */
117
+ LENGTH: 'length',
118
+ /** No published key covers the identifier's date. */
119
+ NO_KEY: 'nokey',
120
+ /** Every candidate key was tried and none verified the signature. */
121
+ SIGNATURE: 'signature'
122
+ });
123
+
124
+ /**
125
+ * An answer from the cloud that was not the one asked for. Carries the HTTP
126
+ * status and the response body so a caller can relay or log what the cloud
127
+ * said.
128
+ */
129
+ class DidClientError extends Error {
130
+ /**
131
+ * Builds the error with the status and body the cloud answered.
132
+ * @param {string} message what went wrong
133
+ * @param {number} [statusCode] the HTTP status, where there was one
134
+ * @param {string} [body] the response body, where there was one
135
+ */
136
+ constructor (message, statusCode, body) {
137
+ super(message);
138
+ this.name = 'DidClientError';
139
+ this.statusCode = statusCode;
140
+ this.body = body;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The 51Did argument is invalid, either when checked locally or refused by
146
+ * the cloud (HTTP 400 with an `errors` list).
147
+ */
148
+ class DidArgumentError extends DidClientError {
149
+ /**
150
+ * Builds the error from the validation message.
151
+ * @param {string} message the validation message
152
+ * @param {number} [statusCode] the HTTP status
153
+ * @param {string} [body] the response body
154
+ */
155
+ constructor (message, statusCode, body) {
156
+ super(message, statusCode, body);
157
+ this.name = 'DidArgumentError';
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The host answering does not offer the creator context (HTTP 404 from the
163
+ * redeem endpoint). A caller can name this case rather than treat it as a
164
+ * failed check.
165
+ */
166
+ class DidNotSupportedError extends DidClientError {
167
+ /**
168
+ * Builds the error from what the host said.
169
+ * @param {string} message what the host said
170
+ * @param {number} [statusCode] the HTTP status
171
+ * @param {string} [body] the response body
172
+ */
173
+ constructor (message, statusCode, body) {
174
+ super(message, statusCode, body);
175
+ this.name = 'DidNotSupportedError';
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The typed answer to a redemption. Built from the cloud's JSON body, with
181
+ * the raw status and body kept for logging.
182
+ */
183
+ class RedeemResult {
184
+ /**
185
+ * Reads the typed fields out of the parsed body.
186
+ * @param {number} statusCode the HTTP status, 200 or 503
187
+ * @param {string} raw the response body as received
188
+ * @param {object} parsed the body parsed as JSON
189
+ */
190
+ constructor (statusCode, raw, parsed) {
191
+ /** @type {number} the HTTP status the cloud answered with */
192
+ this.statusCode = statusCode;
193
+ /** @type {string} the response body exactly as received */
194
+ this.raw = raw;
195
+ const context = typeof parsed.context === 'string' ? parsed.context : '';
196
+ /**
197
+ * @type {string} the `context` string exactly as the cloud sent it,
198
+ * kept so an outcome this package does not know is still visible
199
+ */
200
+ this.contextRaw = context;
201
+ /**
202
+ * @type {string} one of {@link ContextResult}. A string this package
203
+ * does not know maps to `unreadable`, so an unrecognised outcome is
204
+ * never mistaken for a good one.
205
+ */
206
+ this.context = KNOWN_CONTEXTS.has(context)
207
+ ? context
208
+ : ContextResult.UNREADABLE;
209
+ /** @type {string} one of {@link SignatureResult} */
210
+ this.signature = parsed.signature === SignatureResult.VERIFIED
211
+ ? SignatureResult.VERIFIED
212
+ : parsed.signature === SignatureResult.INVALID
213
+ ? SignatureResult.INVALID
214
+ : SignatureResult.UNKNOWN;
215
+ /**
216
+ * @type {object | undefined} factor name to {@link FactorResult} value
217
+ * (or null where nothing was compared), present only when the cloud
218
+ * sent `factors`, which is the mismatch outcome. The names are
219
+ * transport, device, browserip, connectionip, asn and browser.
220
+ */
221
+ this.factors = parsed.factors && typeof parsed.factors === 'object'
222
+ ? Object.freeze(Object.assign({}, parsed.factors))
223
+ : undefined;
224
+ const verifiedAt = typeof parsed.verifiedAt === 'string'
225
+ ? new Date(parsed.verifiedAt)
226
+ : null;
227
+ /**
228
+ * @type {Date | undefined} when the verify endpoint checked the context
229
+ * and sealed the result, present on the redeemed and expired outcomes
230
+ */
231
+ this.verifiedAt = verifiedAt && !isNaN(verifiedAt.getTime())
232
+ ? verifiedAt
233
+ : undefined;
234
+ /**
235
+ * @type {number | undefined} whole seconds between the sealing and this
236
+ * redemption by the cloud's clock, present on the redeemed and expired
237
+ * outcomes
238
+ */
239
+ this.secondsSinceVerified = Number.isFinite(parsed.secondsSinceVerified)
240
+ ? parsed.secondsSinceVerified
241
+ : undefined;
242
+ }
243
+
244
+ /**
245
+ * Builds a result from a redeem response body.
246
+ * @param {number} statusCode the HTTP status
247
+ * @param {string} raw the response body
248
+ * @returns {RedeemResult} the typed result
249
+ */
250
+ static fromResponse (statusCode, raw) {
251
+ const parsed = tryParseJson(raw);
252
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
253
+ throw new DidClientError(
254
+ `Redeem answered HTTP ${statusCode} with a body that is not a ` +
255
+ `JSON object: ${raw}`, statusCode, raw);
256
+ }
257
+ return new RedeemResult(statusCode, raw, parsed);
258
+ }
259
+
260
+ /**
261
+ * The result in the cloud's own response shape (`signature`, `context`,
262
+ * `factors` when present, `verifiedAt`, `secondsSinceVerified`), so a
263
+ * server can answer a page with it directly. `signature` is left out when
264
+ * the cloud did not report it, as the cloud leaves it out.
265
+ * @returns {object} the plain object for JSON.stringify
266
+ */
267
+ toJSON () {
268
+ const body = {};
269
+ if (this.signature !== SignatureResult.UNKNOWN) {
270
+ body.signature = this.signature;
271
+ }
272
+ body.context = this.context;
273
+ if (this.factors !== undefined) {
274
+ body.factors = this.factors;
275
+ }
276
+ if (this.verifiedAt !== undefined) {
277
+ // ISO 8601 UTC to the second, as the cloud writes it.
278
+ body.verifiedAt = this.verifiedAt.toISOString().replace(/\.\d+Z$/, 'Z');
279
+ }
280
+ if (this.secondsSinceVerified !== undefined) {
281
+ body.secondsSinceVerified = this.secondsSinceVerified;
282
+ }
283
+ return body;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * A function with the shape of the global `fetch`, taking a URL and an
289
+ * options object with `method`, `headers` and `body`, and resolving to a
290
+ * response with `status` and a `text()` method. Node 18 and later provide
291
+ * it globally, and tests inject one.
292
+ * @callback FetchFunction
293
+ * @param {string} url the absolute URL to request
294
+ * @param {object} init the request options
295
+ * @returns {Promise<{status: number, text: function(): Promise<string>}>}
296
+ * the response
297
+ */
298
+
299
+ /**
300
+ * A published signing key and the moment it came into force. A key stays in
301
+ * force until the next key starts.
302
+ * @typedef {object} PublicKeyEntry
303
+ * @property {Date} startsAt when the key came, or comes, into force
304
+ * @property {string} publicKey the key in SPKI PEM form
305
+ */
306
+
307
+ /**
308
+ * The detailed answer to an offline signature check.
309
+ * @typedef {object} SignatureCheck
310
+ * @property {boolean} valid whether a candidate key verified the signature
311
+ * @property {string} reason one of {@link SignatureReason}
312
+ */
313
+
314
+ /**
315
+ * Options for {@link DidClient}.
316
+ * @typedef {object} DidClientOptions
317
+ * @property {string} resourceKey the page's resource key. Required. Public
318
+ * by nature, it travels in the route of the key and verify requests and in
319
+ * the form body of the redeem request.
320
+ * @property {string} [licenceKey] a licence key of the same account. Server
321
+ * side only. Needed to redeem where the account holds licence keys, and
322
+ * sent only in the body of the redeem request, never in a URL.
323
+ * @property {string} [endpoint] the API base including the `/api/v4/`
324
+ * segment. Defaults to the FOD_CLOUD_API_URL environment variable, then
325
+ * to the public cloud. A value without a trailing slash gains one.
326
+ * @property {FetchFunction} [fetch] the HTTP transport. Defaults to the
327
+ * global `fetch`.
328
+ * @property {function(): number} [now] the clock, as milliseconds since the
329
+ * Unix epoch. Defaults to `Date.now`. Tests inject one.
330
+ */
331
+
332
+ /**
333
+ * Everything a server does with a 51Did against the 51Degrees cloud: fetch
334
+ * the signing public keys and pick the one in force when an identifier was
335
+ * created, verify a signature offline against it, verify a signature
336
+ * through the cloud, and redeem a sealed creator context result with the
337
+ * licence key.
338
+ *
339
+ * Creating a 51Did is not part of this client. Creation is the cloud `json`
340
+ * endpoint through the cloud request engine and pipeline, and a page
341
+ * creates from the browser because the identifier describes the browser's
342
+ * own connection. The verify-context and verify-full endpoints are browser
343
+ * calls for the same reason and are not offered here.
344
+ *
345
+ * The public key list is cached per instance with the time it was fetched.
346
+ * One instance can serve a whole server.
347
+ */
348
+ class DidClient {
349
+ /**
350
+ * @param {DidClientOptions} options the resource key, and optionally the
351
+ * licence key, endpoint, transport and clock
352
+ */
353
+ constructor (options) {
354
+ if (!options || typeof options.resourceKey !== 'string' ||
355
+ options.resourceKey.length === 0) {
356
+ throw new TypeError('resourceKey is required');
357
+ }
358
+ this._resourceKey = options.resourceKey;
359
+ this._licenceKey = typeof options.licenceKey === 'string' &&
360
+ options.licenceKey.length > 0
361
+ ? options.licenceKey
362
+ : null;
363
+ const endpoint = options.endpoint || process.env.FOD_CLOUD_API_URL ||
364
+ DEFAULT_ENDPOINT;
365
+ // Normalised to end in exactly one slash so every URL is the base plus
366
+ // a relative path, as the cloud request engine treats the same value.
367
+ this._endpoint = endpoint.replace(/\/*$/, '/');
368
+ const fetchFunction = options.fetch || globalThis.fetch;
369
+ if (typeof fetchFunction !== 'function') {
370
+ throw new TypeError('No fetch function is available. Run on Node 18 ' +
371
+ 'or later, or pass one as options.fetch.');
372
+ }
373
+ /** @type {FetchFunction} */
374
+ this._fetch = fetchFunction;
375
+ this._now = typeof options.now === 'function'
376
+ ? options.now
377
+ : () => Date.now();
378
+ /** @type {PublicKeyEntry[] | null} */
379
+ this._keys = null;
380
+ /** @type {number | null} */
381
+ this._fetchedAt = null;
382
+ /** @type {Promise<PublicKeyEntry[]> | null} */
383
+ this._pending = null;
384
+ }
385
+
386
+ /** @returns {string} the API base every request is built on */
387
+ get endpoint () {
388
+ return this._endpoint;
389
+ }
390
+
391
+ /** @returns {string} the resource key the requests carry */
392
+ get resourceKey () {
393
+ return this._resourceKey;
394
+ }
395
+
396
+ /**
397
+ * The published signing keys, oldest first, fetched on first use and
398
+ * then served from the cache until the list is a day old. Keys are
399
+ * published up to three months ahead of their start, so the list holds
400
+ * entries that have not started yet.
401
+ * @returns {Promise<PublicKeyEntry[]>} the keys, oldest start first
402
+ */
403
+ publicKeys () {
404
+ if (this._keys !== null && !this._stale()) {
405
+ return Promise.resolve(this._keys);
406
+ }
407
+ return this._refresh();
408
+ }
409
+
410
+ /**
411
+ * The key in force when the identifier was created, being the entry whose
412
+ * start is latest on or before the identifier's date. The list is fetched
413
+ * again, once, before answering when no entry covers the date, when the
414
+ * date is later than the newest start held, or when the list is more than
415
+ * a day old.
416
+ * @param {FodId | string} fodId the identifier, or its base64
417
+ * @returns {Promise<PublicKeyEntry | null>} the key, or null when the
418
+ * date precedes every published key
419
+ */
420
+ async publicKeyFor (fodId) {
421
+ const id = asFodId(fodId);
422
+ const date = dateOf(id);
423
+ const keys = await this._keysFor(date);
424
+ return inForceAt(keys, date);
425
+ }
426
+
427
+ /**
428
+ * Verifies the identifier's signature offline against the published keys,
429
+ * as the cloud's own verify endpoint does. The envelope version must be
430
+ * the one the cloud signs, the payload must be at least the base length
431
+ * for its type (a longer payload carries a creator context and is
432
+ * accepted), and the signature must verify against the key in force at
433
+ * the identifier's date or, near a period boundary, the neighbouring key.
434
+ * No earlier key is tried.
435
+ * @param {FodId | string} fodId the identifier, or its base64
436
+ * @returns {Promise<boolean>} true when a candidate key verifies it
437
+ */
438
+ async verifySignature (fodId) {
439
+ return (await this.verifySignatureDetailed(fodId)).valid;
440
+ }
441
+
442
+ /**
443
+ * As {@link DidClient#verifySignature}, with the reason alongside the
444
+ * answer, so a caller can tell an identifier no key covers from one whose
445
+ * signature failed.
446
+ * @param {FodId | string} fodId the identifier, or its base64
447
+ * @returns {Promise<SignatureCheck>} the answer and its reason
448
+ */
449
+ async verifySignatureDetailed (fodId) {
450
+ const id = asFodId(fodId);
451
+ if (id.version !== SUPPORTED_VERSION) {
452
+ return { valid: false, reason: SignatureReason.VERSION };
453
+ }
454
+ if (!payloadLengthValid(id)) {
455
+ return { valid: false, reason: SignatureReason.LENGTH };
456
+ }
457
+ const date = dateOf(id);
458
+ const keys = await this._keysFor(date);
459
+ const candidates = candidatesForDate(keys, date);
460
+ if (candidates.length === 0) {
461
+ return { valid: false, reason: SignatureReason.NO_KEY };
462
+ }
463
+ for (const key of candidates) {
464
+ if (await id.verify(key.publicKey)) {
465
+ return { valid: true, reason: SignatureReason.VERIFIED };
466
+ }
467
+ }
468
+ return { valid: false, reason: SignatureReason.SIGNATURE };
469
+ }
470
+
471
+ /**
472
+ * Verifies the identifier's signature through the cloud's verify
473
+ * endpoint, the open endpoint that needs no licence key. One use against
474
+ * the resource key. The identifier is sent under both parameter names,
475
+ * `51did` and `owid`, so the request works with hosts that read either
476
+ * parameter. Hosts that recognise both prefer `51did` and keep `owid` as
477
+ * a compatibility alias.
478
+ * @param {FodId | string} fodId the identifier, or its base64 in either
479
+ * alphabet
480
+ * @returns {Promise<boolean>} whether the cloud found the signature valid
481
+ * @throws {DidArgumentError} when the value is not a 51Did, refused here
482
+ * with the reader's status before any request is made, or when the cloud
483
+ * refuses it (HTTP 400), with the cloud's message
484
+ * @throws {DidClientError} on any other answer than valid or invalid
485
+ */
486
+ async verify (fodId) {
487
+ const id = identifierText(fodId);
488
+ const url = this._endpoint + 'id/verify/' +
489
+ encodeURIComponent(this._resourceKey) +
490
+ '?51did=' + encodeURIComponent(id) +
491
+ '&owid=' + encodeURIComponent(id);
492
+ const response = await this._fetch(url, {
493
+ method: 'GET',
494
+ headers: { 'User-Agent': USER_AGENT }
495
+ });
496
+ const body = await response.text();
497
+ const parsed = tryParseJson(body);
498
+ if (parsed && typeof parsed === 'object') {
499
+ if (typeof parsed.valid === 'boolean') {
500
+ return parsed.valid;
501
+ }
502
+ if (response.status === 400 && Array.isArray(parsed.errors)) {
503
+ throw new DidArgumentError(
504
+ parsed.errors.join(' '), response.status, body);
505
+ }
506
+ }
507
+ throw new DidClientError(
508
+ `Verify answered HTTP ${response.status}: ${body}`,
509
+ response.status, body);
510
+ }
511
+
512
+ /**
513
+ * Redeems a sealed creator context result against the identifier, on the
514
+ * server, with the licence key. The resource key, the 51Did, the sealed
515
+ * result, the challenge and the licence key all travel in the body of a
516
+ * POST to id/redeem, so none of them reaches an access log. (The redeem
517
+ * endpoint takes the resource key in the form on a POST, where the key
518
+ * and verify endpoints take it in the route on a GET.) One use against
519
+ * the resource key, the second of the two a browser context check costs.
520
+ *
521
+ * A 200 and a 503 both produce a result, the 503 being the `unconfirmed`
522
+ * outcome the caller may retry. Every cryptographic failure comes back as
523
+ * the one word `unreadable` by design, so the client does not try to
524
+ * tell them apart either.
525
+ * @param {FodId | string} fodId the identifier the caller knows
526
+ * independently, or its base64 in either alphabet
527
+ * @param {string} result the sealed result exactly as the verify endpoint
528
+ * returned it to the page
529
+ * @param {string} [challenge] the single-use challenge given to the
530
+ * verify endpoint, where one was
531
+ * @returns {Promise<RedeemResult>} the typed outcome
532
+ * @throws {DidArgumentError} when the value is not a 51Did, refused here
533
+ * with the reader's status before any request is made, or when the cloud
534
+ * refuses it (HTTP 400), with the cloud's message
535
+ * @throws {DidNotSupportedError} when the host does not offer the creator
536
+ * context (HTTP 404)
537
+ * @throws {DidClientError} on any other status
538
+ */
539
+ async redeem (fodId, result, challenge) {
540
+ const id = identifierText(fodId);
541
+ const form = new URLSearchParams();
542
+ form.set('resource', this._resourceKey);
543
+ form.set('51did', id);
544
+ form.set('result', typeof result === 'string' ? result : '');
545
+ form.set('challenge', typeof challenge === 'string' ? challenge : '');
546
+ if (this._licenceKey !== null) {
547
+ form.set('license', this._licenceKey);
548
+ }
549
+ const url = this._endpoint + 'id/redeem';
550
+ const response = await this._fetch(url, {
551
+ method: 'POST',
552
+ headers: {
553
+ 'User-Agent': USER_AGENT,
554
+ 'Content-Type': 'application/x-www-form-urlencoded'
555
+ },
556
+ body: form.toString()
557
+ });
558
+ const body = await response.text();
559
+ if (response.status === 200 || response.status === 503) {
560
+ return RedeemResult.fromResponse(response.status, body);
561
+ }
562
+ if (response.status === 400) {
563
+ const parsed = tryParseJson(body);
564
+ const message = parsed && Array.isArray(parsed.errors)
565
+ ? parsed.errors.join(' ')
566
+ : body;
567
+ throw new DidArgumentError(message, response.status, body);
568
+ }
569
+ if (response.status === 404) {
570
+ throw new DidNotSupportedError(
571
+ 'The host does not offer the creator context: ' + body,
572
+ response.status, body);
573
+ }
574
+ throw new DidClientError(
575
+ `Redeem answered HTTP ${response.status}: ${body}`,
576
+ response.status, body);
577
+ }
578
+
579
+ /**
580
+ * The key list to select from for the given date, fetched again once
581
+ * where the rule in {@link DidClient#publicKeyFor} calls for it and the
582
+ * list was not just fetched.
583
+ * @param {Date} date the identifier's date
584
+ * @returns {Promise<PublicKeyEntry[]>} the keys to select from
585
+ * @private
586
+ */
587
+ async _keysFor (date) {
588
+ const fetchedBefore = this._fetchedAt;
589
+ let keys = await this.publicKeys();
590
+ if (this._fetchedAt === fetchedBefore && this._needsRefetch(keys, date)) {
591
+ keys = await this._refresh();
592
+ }
593
+ return keys;
594
+ }
595
+
596
+ /**
597
+ * Whether the held list should be fetched again before selecting for the
598
+ * date.
599
+ * @param {PublicKeyEntry[]} keys the held list, oldest first
600
+ * @param {Date} date the identifier's date
601
+ * @returns {boolean} true to fetch again
602
+ * @private
603
+ */
604
+ _needsRefetch (keys, date) {
605
+ if (inForceAt(keys, date) === null) {
606
+ return true;
607
+ }
608
+ const newestStart = keys[keys.length - 1].startsAt;
609
+ if (date.getTime() > newestStart.getTime()) {
610
+ return true;
611
+ }
612
+ return this._stale();
613
+ }
614
+
615
+ /**
616
+ * @returns {boolean} whether the held list is missing or over a day old
617
+ * @private
618
+ */
619
+ _stale () {
620
+ return this._fetchedAt === null ||
621
+ this._now() - this._fetchedAt > KEY_LIST_MAX_AGE_MS;
622
+ }
623
+
624
+ /**
625
+ * Fetches the key list, sharing one request between concurrent callers.
626
+ * @returns {Promise<PublicKeyEntry[]>} the fresh list
627
+ * @private
628
+ */
629
+ _refresh () {
630
+ if (this._pending === null) {
631
+ this._pending = this._fetchKeys()
632
+ .then((keys) => {
633
+ this._keys = keys;
634
+ this._fetchedAt = this._now();
635
+ return keys;
636
+ })
637
+ .finally(() => {
638
+ this._pending = null;
639
+ });
640
+ }
641
+ return this._pending;
642
+ }
643
+
644
+ /**
645
+ * GET id/key/{resource} and read each entry's start and public key.
646
+ * `startsAt` is read where present and `created` otherwise. Both are
647
+ * supported start fields in key-list responses. `weekStart` is ignored.
648
+ * @returns {Promise<PublicKeyEntry[]>} the keys, oldest start first
649
+ * @private
650
+ */
651
+ async _fetchKeys () {
652
+ const url = this._endpoint + 'id/key/' +
653
+ encodeURIComponent(this._resourceKey);
654
+ const response = await this._fetch(url, {
655
+ method: 'GET',
656
+ headers: { 'User-Agent': USER_AGENT }
657
+ });
658
+ const body = await response.text();
659
+ if (response.status !== 200) {
660
+ throw new DidClientError(
661
+ `Public keys answered HTTP ${response.status}: ${body}`,
662
+ response.status, body);
663
+ }
664
+ const parsed = tryParseJson(body);
665
+ if (!Array.isArray(parsed)) {
666
+ throw new DidClientError(
667
+ 'Public keys answered with a body that is not a JSON array: ' +
668
+ body, response.status, body);
669
+ }
670
+ const keys = parsed.map((entry) => {
671
+ const start = entry && (entry.startsAt || entry.created);
672
+ const startsAt = typeof start === 'string' ? new Date(start) : null;
673
+ if (startsAt === null || isNaN(startsAt.getTime()) ||
674
+ typeof entry.publicKey !== 'string') {
675
+ throw new DidClientError(
676
+ 'Public keys entry lacks a start or a publicKey: ' +
677
+ JSON.stringify(entry), response.status, body);
678
+ }
679
+ return Object.freeze({ startsAt, publicKey: entry.publicKey });
680
+ });
681
+ keys.sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime());
682
+ return Object.freeze(keys);
683
+ }
684
+ }
685
+
686
+ /**
687
+ * The identifier as a FodId, parsing a base64 string where one was given. A
688
+ * string the reader cannot parse is reported as a DidArgumentError, the same
689
+ * type the length guard and the cloud's own refusal use, so a caller
690
+ * matching on DidClientError catches every bad argument in one place.
691
+ * @param {FodId | string} value an identifier or its base64
692
+ * @returns {FodId} the identifier
693
+ */
694
+ function asFodId (value) {
695
+ if (value instanceof FodId) {
696
+ return value;
697
+ }
698
+ if (typeof value === 'string') {
699
+ ensureEncodedLength(value);
700
+ return parseOrRefuse(value);
701
+ }
702
+ throw new TypeError('fodId must be a FodId or a base64 string');
703
+ }
704
+
705
+ /**
706
+ * The text sent to the cloud for an identifier. A parsed identifier goes in
707
+ * the URL-safe alphabet, which needs no further encoding. A string is read
708
+ * here first, so a value that is not a 51Did is refused before any request
709
+ * is made, and then goes as given so the cloud sees what the page sent.
710
+ * @param {FodId | string} value an identifier or its base64
711
+ * @returns {string} the text to send
712
+ */
713
+ function identifierText (value) {
714
+ if (value instanceof FodId) {
715
+ return value.asBase64Url();
716
+ }
717
+ if (typeof value === 'string' && value.length > 0) {
718
+ ensureEncodedLength(value);
719
+ parseOrRefuse(value);
720
+ return value;
721
+ }
722
+ throw new TypeError('fodId must be a FodId or a non-empty base64 string');
723
+ }
724
+
725
+ /**
726
+ * Reads a string as a 51Did, or refuses it as a DidArgumentError naming the
727
+ * reason. The reason is the reader's own status, so a caller sees why the
728
+ * value was refused without any key being fetched or any request made.
729
+ * @param {string} value the encoded identifier, already within the length
730
+ * guard
731
+ * @returns {FodId} the identifier
732
+ */
733
+ function parseOrRefuse (value) {
734
+ const read = FodId.tryParse(value);
735
+ if (!read.ok) {
736
+ throw new DidArgumentError(
737
+ 'The value could not be read as a 51Did (' + read.status + ').');
738
+ }
739
+ return read.value;
740
+ }
741
+
742
+ /**
743
+ * Turns away an encoded value too long to be worth decoding, before any
744
+ * work is done on it. Whitespace at either end is ignored, as the reader
745
+ * ignores it.
746
+ * @param {string} value the encoded identifier as the caller gave it
747
+ */
748
+ function ensureEncodedLength (value) {
749
+ if (value.trim().length > MAXIMUM_ENCODED_LENGTH) {
750
+ throw new DidArgumentError(
751
+ 'The value is longer than this client will read as a 51Did.');
752
+ }
753
+ }
754
+
755
+ /**
756
+ * The identifier's creation moment as a Date.
757
+ * @param {FodId} fodId the identifier
758
+ * @returns {Date} the moment the envelope says it was created
759
+ */
760
+ function dateOf (fodId) {
761
+ return new Date(OWID_EPOCH_MS + fodId.dateMinutes * MINUTE_MS);
762
+ }
763
+
764
+ /**
765
+ * Whether the payload is at least the base length for its type, being five
766
+ * header bytes plus a 32 byte match key, or 16 for a Random identifier.
767
+ * Anything beyond the base is a creator context section, whose exact
768
+ * lengths belong to the cloud, so any longer payload is accepted here. The
769
+ * reader already refuses a Probabilistic, HashedEmail or Random payload
770
+ * shorter than its base, so in practice only a Reserved identifier, which
771
+ * the reader accepts at any length from the header up, reaches this check
772
+ * with too few bytes.
773
+ * @param {FodId} fodId the identifier
774
+ * @returns {boolean} whether the length is acceptable
775
+ */
776
+ function payloadLengthValid (fodId) {
777
+ const matchKeyLength = fodId.type === IdType.RANDOM
778
+ ? FodId.GUID_LENGTH
779
+ : FodId.HASH_LENGTH;
780
+ return fodId.payload.length >= FodId.HEADER_LENGTH + matchKeyLength;
781
+ }
782
+
783
+ /**
784
+ * The entry in force at the moment, being the newest whose start has
785
+ * passed, or null when the moment precedes every entry.
786
+ * @param {PublicKeyEntry[]} keys the schedule, in any order
787
+ * @param {Date} at the moment
788
+ * @returns {PublicKeyEntry | null} the entry in force
789
+ */
790
+ function inForceAt (keys, at) {
791
+ let best = null;
792
+ for (const key of keys) {
793
+ if (key.startsAt.getTime() > at.getTime()) {
794
+ continue;
795
+ }
796
+ if (best === null || key.startsAt.getTime() > best.startsAt.getTime()) {
797
+ best = key;
798
+ }
799
+ }
800
+ return best;
801
+ }
802
+
803
+ /**
804
+ * The entries that may have signed something created at the moment, best
805
+ * first: the entry in force, then the entry in force a tolerance earlier
806
+ * and the entry in force a tolerance later where those differ. Not every
807
+ * earlier entry.
808
+ * @param {PublicKeyEntry[]} keys the schedule, in any order
809
+ * @param {Date} at the moment
810
+ * @returns {PublicKeyEntry[]} the entries to try, best first
811
+ */
812
+ function candidatesForDate (keys, at) {
813
+ const candidates = [];
814
+ const add = (entry) => {
815
+ if (entry !== null && candidates.indexOf(entry) < 0) {
816
+ candidates.push(entry);
817
+ }
818
+ };
819
+ add(inForceAt(keys, at));
820
+ add(inForceAt(keys, new Date(at.getTime() - BOUNDARY_TOLERANCE_MS)));
821
+ add(inForceAt(keys, new Date(at.getTime() + BOUNDARY_TOLERANCE_MS)));
822
+ return candidates;
823
+ }
824
+
825
+ /**
826
+ * Parses JSON without throwing.
827
+ * @param {string} text a response body
828
+ * @returns {any} the parsed JSON, or null when the text is not JSON
829
+ */
830
+ function tryParseJson (text) {
831
+ try {
832
+ return JSON.parse(text);
833
+ } catch (error) {
834
+ return null;
835
+ }
836
+ }
837
+
838
+ module.exports = {
839
+ DidClient,
840
+ RedeemResult,
841
+ ContextResult,
842
+ SignatureResult,
843
+ FactorResult,
844
+ SignatureReason,
845
+ DidClientError,
846
+ DidArgumentError,
847
+ DidNotSupportedError,
848
+ DEFAULT_ENDPOINT
849
+ };