pryv 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,8 +114,23 @@ Here is an implementation of the [Pryv.io authentication process](https://api.pr
114
114
  streamId: 'test',
115
115
  defaultName: 'test',
116
116
  level: 'manage'
117
+ },
118
+ {
119
+ streamId: 'location',
120
+ defaultName: 'Location',
121
+ level: 'read'
117
122
  }
118
123
  ],
124
+ // optional: how the consent screen should present each permission.
125
+ // Ids are a stream permission's streamId (or a feature permission's
126
+ // feature). Ignored by older cores, which fall back to
127
+ // all-or-nothing rather than failing.
128
+ consent: {
129
+ allowUserChoice: true, // false (the default) = accept all or deny
130
+ mandatory: ['test'], // required: cannot be unticked
131
+ optIn: ['location'] // offered UNticked; the user opts in
132
+ // a permission in neither list is optional and shown pre-selected
133
+ },
119
134
  clientData: {
120
135
  'app-web-auth:description': {
121
136
  'type': 'note/txt', 'content': 'This is a consent message.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.10.0",
3
+ "version": "3.12.0",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
@@ -70,7 +70,14 @@ class AuthController {
70
70
  const loginButton = this.loginButton;
71
71
  // initialize human interaction interface
72
72
  if (loginButton != null) {
73
- this.stateChangeListeners.push(loginButton.onStateChange.bind(loginButton));
73
+ // Register the button's state listener at most once: init() can run again
74
+ // on the same controller (the LoginButton re-inits after a confirmed
75
+ // logout), and a duplicate listener would fire the logout confirm twice
76
+ // and compound the listener list on every re-login.
77
+ if (!this._loginButtonListenerRegistered) {
78
+ this.stateChangeListeners.push(loginButton.onStateChange.bind(loginButton));
79
+ this._loginButtonListenerRegistered = true;
80
+ }
74
81
  // autologin needs cookies/storage implemented in human interaction interface
75
82
  await checkAutoLogin(this);
76
83
  }
@@ -245,9 +252,15 @@ class AuthController {
245
252
 
246
253
  this._state = newState;
247
254
 
255
+ // Dispatch the state that was just set (`newState`), NOT the live `this.state`
256
+ // getter: a listener that synchronously changes the state mid-dispatch (e.g.
257
+ // the LoginButton's logout confirm re-initializing to INITIALIZED) would
258
+ // otherwise overwrite `this._state`, so later listeners in this loop would
259
+ // receive the wrong state (a logout would deliver INITIALIZED instead of
260
+ // SIGNOUT to the app's onStateChange).
248
261
  this.stateChangeListeners.forEach((listener) => {
249
262
  try {
250
- listener(this.state);
263
+ listener(newState);
251
264
  } catch (e) {
252
265
  console.log('Error during set state ()', e);
253
266
  }
package/src/Service.js CHANGED
@@ -473,13 +473,28 @@ class Service {
473
473
  * @param {Object} authRequest - The auth-request body
474
474
  * @param {string} authRequest.requestingAppId
475
475
  * @param {Array<{ streamId: string, level: string, defaultName: string }>} authRequest.requestedPermissions
476
+ * @param {Object} [authRequest.consent] - Says how the consent screen
477
+ * should present each requested permission. `mandatory` and `optIn`
478
+ * name permission ids (a stream permission's `streamId`, a feature
479
+ * permission's `feature`); an id in neither list is optional and
480
+ * shown pre-selected. Ignored by cores that predate it, so the flow
481
+ * falls back to all-or-nothing rather than failing: the returned
482
+ * `consent` tells you which happened.
483
+ * @param {boolean} [authRequest.consent.allowUserChoice=false] - false
484
+ * means the user may only accept the whole set or deny.
485
+ * @param {string[]} [authRequest.consent.mandatory] - ids the user
486
+ * cannot leave out.
487
+ * @param {string[]} [authRequest.consent.optIn] - ids offered NOT
488
+ * pre-selected, so the user has to choose them.
476
489
  * @param {string} [authRequest.languageCode='en']
477
490
  * @param {string|boolean} [authRequest.returnUrl]
478
491
  * @param {string} [authRequest.referer]
479
492
  * @param {Object} [authRequest.clientData]
480
493
  * @param {string} [authRequest.deviceName]
481
494
  * @param {number} [authRequest.expireAfter]
482
- * @returns {Promise<{ key: string, authUrl: string, poll: string, pollRateMs: number }>}
495
+ * @returns {Promise<{ key: string, authUrl: string, poll: string, pollRateMs: number, consent?: Object }>}
496
+ * `consent` is echoed back only by a core that understood the
497
+ * annotations, which is how you detect support.
483
498
  * @throws {PryvError} on non-2xx
484
499
  */
485
500
  async startAccessRequest (authRequest) {
@@ -499,12 +514,18 @@ class Service {
499
514
  'Invalid access-request response: ' + JSON.stringify(body)
500
515
  );
501
516
  }
502
- return {
517
+ const envelope = {
503
518
  key: body.key,
504
519
  authUrl: body.authUrl || body.url,
505
520
  poll: body.poll,
506
521
  pollRateMs: body.poll_rate_ms != null ? body.poll_rate_ms : body.pollRateMs
507
522
  };
523
+ // Present only when the core understood a `consent` sidecar. Absent
524
+ // means the annotations were ignored and the consent screen will be
525
+ // all-or-nothing, which a caller may want to know before showing the
526
+ // approve link.
527
+ if (body.consent != null) envelope.consent = body.consent;
528
+ return envelope;
508
529
  }
509
530
 
510
531
  /**
@@ -86,15 +86,19 @@ async function hmacSha256Hex (verifierSecret, message) {
86
86
  * @param {number} params.ttl seconds the secret stays redeemable (required)
87
87
  * @param {string} params.title shown to the account owner (required)
88
88
  * @param {Object} params.onConsumed `{ message, returnUrl? }` shown once spent
89
- * @param {*} params.secret the payload — any JSON value
89
+ * @param {*} params.secret the payload: any non-null JSON value (the server
90
+ * refuses `null` and `undefined` with `shared-secret-missing-secret`)
90
91
  * @param {Object} [params.signature] `{ type: 'secret', value }` or
91
92
  * `{ type: 'hmac-sha256', verifierSecret }`. For the HMAC form the key
92
93
  * material is generated here so the proof can be bound before creation.
93
- * @returns {Promise<Object>} `{ id, key, expires, ... }` — `key` is returned
94
+ * @returns {Promise<Object>} `{ id, key, expires, ... }`; `key` is returned
94
95
  * only here and cannot be recovered later.
95
96
  */
96
97
  async function create (connection, params) {
97
98
  const { signature, ...rest } = params || {};
99
+ /** The request body: built here, so its shape is wider than `params`
100
+ * (the HMAC form adds `keyHash` and a computed `signature`).
101
+ * @type {Record<string, any>} */
98
102
  const body = { ...rest };
99
103
  let key = null;
100
104
 
@@ -107,12 +111,12 @@ async function create (connection, params) {
107
111
  type: 'hmac-sha256',
108
112
  value: await hmacSha256Hex(signature.verifierSecret, random)
109
113
  };
110
- const res = await connection.post('shared-secrets', body);
111
- return { ...res.sharedSecret, key: res.sharedSecret.id + '.' + random };
114
+ const hmacRes = /** @type {any} */ (await connection.post('shared-secrets', body));
115
+ return { ...hmacRes.sharedSecret, key: hmacRes.sharedSecret.id + '.' + random };
112
116
  }
113
117
 
114
118
  if (signature != null) body.signature = signature;
115
- const res = await connection.post('shared-secrets', body);
119
+ const res = /** @type {any} */ (await connection.post('shared-secrets', body));
116
120
  key = res.sharedSecret.key;
117
121
  return { ...res.sharedSecret, key };
118
122
  }
@@ -148,7 +152,11 @@ async function retrieve (apiEndpoint, key, options = {}) {
148
152
  });
149
153
  const parsed = await res.json();
150
154
  if (!res.ok) {
151
- const err = new Error(parsed?.error?.message || 'Shared secret unavailable.');
155
+ // The API error id and the creator's returnUrl ride on the Error so a
156
+ // caller can tell WHY it was refused and where to send the user next.
157
+ const err = /** @type {Error & { id?: string, returnUrl?: string }} */ (
158
+ new Error(parsed?.error?.message || 'Shared secret unavailable.')
159
+ );
152
160
  err.id = parsed?.error?.id;
153
161
  err.returnUrl = parsed?.error?.data?.returnUrl;
154
162
  throw err;
package/src/index.d.ts CHANGED
@@ -172,12 +172,14 @@ declare module 'pryv' {
172
172
  * Thrown by `Service.login` when the platform returned `{ mfaToken }`
173
173
  * instead of `{ token }`. Carries the `mfaToken` so the consumer can
174
174
  * call `Service.mfaVerify(userId, err.mfaToken, code)` after prompting
175
- * the user for their SMS code.
175
+ * the user for their code. `method` (when present) says which factor to
176
+ * prompt for: `'totp'` (authenticator app) or `'sms'`.
176
177
  */
177
178
  export class MfaRequiredError extends PryvError {
178
179
  constructor(mfaToken: string, response: Response, body?: any);
179
180
  name: 'MfaRequiredError';
180
181
  mfaToken: string;
182
+ method?: 'totp' | 'sms' | string;
181
183
  }
182
184
 
183
185
  /** Catalogue of Pryv API error ids (mirrors open-pryv.io ErrorIds.js). */
@@ -869,8 +871,16 @@ declare module 'pryv' {
869
871
  authUrl: string;
870
872
  poll: string;
871
873
  pollRateMs: number;
874
+ /** Echoed only by a core that understood `authRequest.consent`. */
875
+ consent?: AuthRequestConsentForm;
872
876
  }>;
873
877
  pollAccessRequest(keyOrPollUrl: string): Promise<any>;
878
+ /**
879
+ * Resolve an auth-flow polling `key` (from {@link Service.startAccessRequest})
880
+ * into a working {@link Connection}. Polls the access request once; throws a
881
+ * {@link PryvError} unless the access is `ACCEPTED`.
882
+ */
883
+ connectFromKey(key: string): Promise<Connection>;
874
884
 
875
885
  static buildAPIEndpoint(
876
886
  serviceInfo: ServiceInfo,
@@ -879,12 +889,74 @@ declare module 'pryv' {
879
889
  ): string;
880
890
  }
881
891
 
892
+ /**
893
+ * Module-level convenience over {@link Service#connectFromKey}: builds a
894
+ * transient {@link Service} for `serviceInfoUrl`, fetches its info, and
895
+ * resolves the auth-flow polling `key` into a working {@link Connection}.
896
+ * Mirrors the `pryv.connectFromKey(key, serviceInfoUrl)` shape the headless
897
+ * polling pattern documents.
898
+ */
899
+ export function connectFromKey(
900
+ key: string,
901
+ serviceInfoUrl: string,
902
+ serviceCustomizations?: serviceCustomizations,
903
+ ): Promise<Connection>;
904
+
882
905
  export type AuthRequestedPermission = {
883
906
  streamId: Identifier;
884
907
  defaultName: string;
885
908
  level: PermissionLevel;
886
909
  };
887
910
 
911
+ /**
912
+ * How the consent screen should present each requested permission.
913
+ *
914
+ * The lists name permission ids: a stream permission's `streamId`, a
915
+ * feature permission's `feature`. Together they give three words for
916
+ * what an entry means to the user:
917
+ *
918
+ * mandatory listed in `mandatory` : required; cannot be unticked
919
+ * opt-out in neither list : optional, shown pre-selected
920
+ * opt-in listed in `optIn` : optional, shown NOT pre-selected
921
+ *
922
+ * An id may not appear in both lists, and every id must match exactly
923
+ * one requested permission. A core that predates this ignores the whole
924
+ * object, so the consent falls back to all-or-nothing instead of
925
+ * failing; `AuthRequestConsentForm` echoed back is how you tell.
926
+ */
927
+ export type AuthRequestConsent = {
928
+ /** Default false: the user may only accept the whole set or deny. */
929
+ allowUserChoice?: boolean;
930
+ mandatory?: string[];
931
+ optIn?: string[];
932
+ };
933
+
934
+ /**
935
+ * The resolved consent form: the requested permissions with their
936
+ * annotations attached, as the auth page renders them. Returned by the
937
+ * auth-request POST and carried on the NEED_SIGNIN poll, in both cases
938
+ * ONLY when the request carried a `consent` object the core understood.
939
+ */
940
+ export type AuthRequestConsentForm = {
941
+ allowUserChoice: boolean;
942
+ permissions: Array<
943
+ (
944
+ | {
945
+ streamId: Identifier;
946
+ level: PermissionLevel;
947
+ /** Echoed only when the request carried it. */
948
+ defaultName?: string;
949
+ /** Present when the core resolved the stream's real name. */
950
+ name?: string;
951
+ }
952
+ | { feature: string; setting: string }
953
+ ) & {
954
+ mandatory?: true;
955
+ optIn?: true;
956
+ }
957
+ >;
958
+ };
959
+
888
960
  export type States =
889
961
  | 'ERROR'
890
962
  | 'LOADING'
@@ -917,6 +989,7 @@ declare module 'pryv' {
917
989
  level: PermissionLevel;
918
990
  defaultName: string;
919
991
  }>;
992
+ consent?: AuthRequestConsentForm;
920
993
  requestingAppId: string;
921
994
  returnUrl?: string | null;
922
995
  serviceInfo?: ServiceInfo;
@@ -954,6 +1027,7 @@ declare module 'pryv' {
954
1027
  poll_rate_ms: number;
955
1028
  requestingAppId: string;
956
1029
  requestedPermissions: AuthRequestedPermission[];
1030
+ consent?: AuthRequestConsentForm;
957
1031
  lang?: string;
958
1032
  returnURL?: string;
959
1033
  clientData?: KeyValue;
@@ -968,6 +1042,7 @@ declare module 'pryv' {
968
1042
  requestingAppId: string;
969
1043
  languageCode?: string;
970
1044
  requestedPermissions: AuthRequestedPermission[];
1045
+ consent?: AuthRequestConsent;
971
1046
  returnUrl?: string | boolean;
972
1047
  referer?: string;
973
1048
  clientData?: KeyValue;
@@ -1200,6 +1275,7 @@ declare module 'pryv' {
1200
1275
  MfaRequiredError: typeof MfaRequiredError;
1201
1276
  ERRORS: typeof ERRORS;
1202
1277
  version: version;
1278
+ connectFromKey: typeof connectFromKey;
1203
1279
  };
1204
1280
 
1205
1281
  export default pryv;
@@ -8,7 +8,10 @@ const PryvError = require('./PryvError');
8
8
  /**
9
9
  * Thrown by `Service.login` when the platform replied with `{ mfaToken }`
10
10
  * instead of `{ token }`. Consumers catch this, prompt the user for the
11
- * SMS code, then call `Service.mfaVerify(userId, err.mfaToken, code)`.
11
+ * code, then call `Service.mfaVerify(userId, err.mfaToken, code)`.
12
+ *
13
+ * `err.method` (when present) tells the app which factor to prompt for:
14
+ * `'totp'` (an authenticator-app code) or `'sms'` (a code sent by SMS).
12
15
  *
13
16
  * try { conn = await service.login(u, p, app) }
14
17
  * catch (err) {
@@ -33,6 +36,8 @@ class MfaRequiredError extends PryvError {
33
36
  this.name = 'MfaRequiredError';
34
37
  /** @type {string} */
35
38
  this.mfaToken = mfaToken;
39
+ /** @type {string|undefined} The MFA method to prompt for: 'totp' | 'sms'. */
40
+ this.method = body && body.mfaMethod;
36
41
  this.id = (apiErr && apiErr.id) || 'mfa-required';
37
42
  this.status = response && response.status;
38
43
  this.response = { body, status: response && response.status };
@@ -7,11 +7,13 @@
7
7
  const utils = require('../src/utils.js');
8
8
  const Service = require('../src/Service');
9
9
  const AuthController = require('../src/Auth/AuthController.js');
10
+ const AuthStates = require('../src/Auth/AuthStates');
10
11
 
11
12
  describe('[AUTX] Browser.LoginButton', function () {
12
13
  this.timeout(15000);
13
14
 
14
15
  let auth;
16
+ let service;
15
17
  let cleanupDom = false;
16
18
  before(async function () {
17
19
  if (typeof document !== 'undefined') return; // in browser
@@ -32,7 +34,7 @@ describe('[AUTX] Browser.LoginButton', function () {
32
34
  delete global.location;
33
35
  });
34
36
  before(async function () {
35
- const service = new Service(testData.serviceInfoUrl);
37
+ service = new Service(testData.serviceInfoUrl);
36
38
  await service.info();
37
39
  auth = new AuthController({
38
40
  authRequest: {
@@ -92,4 +94,75 @@ describe('[AUTX] Browser.LoginButton', function () {
92
94
  expect('https://my.Url.com/#bobby=2').to.equal(utils.cleanURLFromPrYvParams(
93
95
  'https://my.Url.com/#bobby=2&prYvZoutOu=1&prYvstatus=2jsadh'));
94
96
  });
97
+
98
+ // Logout state dispatch (https://github.com/pryv/lib-js/issues/13): a listener
99
+ // that synchronously re-enters the setter (the LoginButton's logout confirm
100
+ // re-initializing to INITIALIZED) must not corrupt the state delivered to the
101
+ // other listeners in the same dispatch, and init() must not register the button
102
+ // listener twice (which would fire the logout confirm dialog more than once).
103
+ describe('[ACLO] logout state dispatch (re-entrancy)', function () {
104
+ function makeAuth (onStateChange) {
105
+ return new AuthController({
106
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] },
107
+ onStateChange
108
+ }, service);
109
+ }
110
+
111
+ it('[ACLO1] the app onStateChange receives SIGNOUT on logout, then INITIALIZED', async function () {
112
+ const seen = [];
113
+ const a = makeAuth((s) => seen.push(s.status));
114
+ await a.init();
115
+ // Mimic LoginButton (registered after the external app listener): on
116
+ // SIGNOUT it confirms + re-initializes, synchronously driving the state
117
+ // to INITIALIZED during the same dispatch.
118
+ a.stateChangeListeners.push((s) => {
119
+ if (s.status === AuthStates.SIGNOUT) a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
120
+ });
121
+ a._state = { status: AuthStates.AUTHORIZED };
122
+ seen.length = 0;
123
+ await a.handleClick(); // AUTHORIZED -> SIGNOUT
124
+ expect(seen).to.include(AuthStates.SIGNOUT);
125
+ expect(seen[seen.length - 1]).to.equal(AuthStates.INITIALIZED);
126
+ });
127
+
128
+ it('[ACLO2] a listener re-entering the setter must not corrupt the state delivered to later listeners', function () {
129
+ // The setter must hand each listener the state dispatched to IT, not a value
130
+ // an earlier listener mutated mid-dispatch. Worst case: a re-entrant listener
131
+ // registered BEFORE the recorder.
132
+ const a = makeAuth(() => {});
133
+ const seen = [];
134
+ let reentered = false;
135
+ a.stateChangeListeners.push((s) => {
136
+ if (s.status === AuthStates.SIGNOUT && !reentered) {
137
+ reentered = true;
138
+ a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
139
+ }
140
+ });
141
+ a.stateChangeListeners.push((s) => seen.push(s.status));
142
+ seen.length = 0;
143
+ a.state = { status: AuthStates.SIGNOUT };
144
+ // The re-entrant INITIALIZED dispatch completes first (inner), then the outer
145
+ // SIGNOUT dispatch resumes to the recorder; the recorder MUST see SIGNOUT.
146
+ expect(seen).to.deep.equal([AuthStates.INITIALIZED, AuthStates.SIGNOUT]);
147
+ });
148
+
149
+ it('[ACLO3] init() registers the LoginButton listener only once, so logout confirms exactly once', async function () {
150
+ let confirms = 0;
151
+ const fakeButton = {
152
+ onStateChange: (s) => { if (s.status === AuthStates.SIGNOUT) confirms++; },
153
+ getAuthorizationData: () => null
154
+ };
155
+ const a = new AuthController({
156
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
157
+ }, service, fakeButton);
158
+ await a.init();
159
+ const nAfterFirst = a.stateChangeListeners.length;
160
+ await a.init(); // the confirmed-logout path re-inits the same controller
161
+ await a.init();
162
+ expect(a.stateChangeListeners.length).to.equal(nAfterFirst); // no compounding
163
+ confirms = 0;
164
+ a.state = { status: AuthStates.SIGNOUT }; // one dispatch -> one confirm
165
+ expect(confirms).to.equal(1);
166
+ });
167
+ });
95
168
  });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, afterEach, expect, pryv */
6
+
7
+ /**
8
+ * [ARQC] The `consent` sidecar of an auth request travels untouched.
9
+ *
10
+ * The sidecar is how an app says which permissions are required, which are
11
+ * offered pre-selected, and which the user must opt into. This library must
12
+ * neither invent it nor filter it: it posts what the caller gave it and
13
+ * hands back what the core answered. These pin that, so a future "helpful"
14
+ * field whitelist in `startAccessRequest` cannot silently drop consent
15
+ * semantics on their way to the server.
16
+ *
17
+ * Driven through a stubbed global fetch rather than a live core: what is
18
+ * being tested is this library's pass-through, and a core that predates
19
+ * the sidecar is one of the cases.
20
+ */
21
+
22
+ const SERVICE_INFO = {
23
+ register: 'https://reg.test.local/',
24
+ access: 'https://reg.test.local/access',
25
+ api: 'https://{username}.test.local/',
26
+ name: 'Test'
27
+ };
28
+
29
+ const CONSENT = {
30
+ allowUserChoice: true,
31
+ mandatory: ['diary'],
32
+ optIn: ['weight']
33
+ };
34
+
35
+ const REQUEST = {
36
+ requestingAppId: 'jslib-test',
37
+ requestedPermissions: [
38
+ { streamId: 'diary', level: 'read', defaultName: 'Journal' },
39
+ { streamId: 'weight', level: 'read', defaultName: 'Weight' }
40
+ ],
41
+ consent: CONSENT
42
+ };
43
+
44
+ describe('[ARQC] auth-request consent sidecar', function () {
45
+ let realFetch;
46
+ let posted;
47
+
48
+ /** Stub fetch: serve service-info, capture the access POST, answer it. */
49
+ function stubFetch (accessResponse) {
50
+ realFetch = global.fetch;
51
+ posted = [];
52
+ global.fetch = async function (url, options) {
53
+ if (String(url).includes('/access')) {
54
+ posted.push(JSON.parse(options.body));
55
+ return {
56
+ ok: true,
57
+ status: 201,
58
+ json: async () => accessResponse
59
+ };
60
+ }
61
+ return { ok: true, status: 200, json: async () => SERVICE_INFO };
62
+ };
63
+ }
64
+
65
+ afterEach(function () {
66
+ if (realFetch) global.fetch = realFetch;
67
+ realFetch = null;
68
+ });
69
+
70
+ it('[SARC1] posts the consent sidecar verbatim', async function () {
71
+ stubFetch({ key: 'k1', authUrl: 'https://auth/', poll: 'https://poll/k1', poll_rate_ms: 1000 });
72
+ const service = new pryv.Service('https://reg.test.local/service/info');
73
+ await service.startAccessRequest(REQUEST);
74
+ expect(posted).to.have.lengthOf(1);
75
+ expect(posted[0].consent).to.deep.equal(CONSENT);
76
+ // And the permissions stay plain: the annotations live in the sidecar,
77
+ // never inside an entry (the API rejects unknown per-entry fields).
78
+ expect(posted[0].requestedPermissions).to.deep.equal(REQUEST.requestedPermissions);
79
+ });
80
+
81
+ it('[SARC2] returns the consent form a core echoes back, and nothing when it does not', async function () {
82
+ const form = {
83
+ allowUserChoice: true,
84
+ permissions: [
85
+ { streamId: 'diary', level: 'read', defaultName: 'Journal', mandatory: true },
86
+ { streamId: 'weight', level: 'read', defaultName: 'Weight', optIn: true }
87
+ ]
88
+ };
89
+ stubFetch({
90
+ key: 'k1',
91
+ authUrl: 'https://auth/',
92
+ poll: 'https://poll/k1',
93
+ poll_rate_ms: 1000,
94
+ consent: form
95
+ });
96
+ const service = new pryv.Service('https://reg.test.local/service/info');
97
+ const env = await service.startAccessRequest(REQUEST);
98
+ // The echo is how an app knows the core understood the annotations.
99
+ expect(env.consent).to.deep.equal(form);
100
+ if (realFetch) { global.fetch = realFetch; realFetch = null; }
101
+
102
+ // A core that predates the sidecar answers without it. The request
103
+ // still succeeds and the consent screen falls back to all-or-nothing,
104
+ // so the absence is the signal, not an error.
105
+ stubFetch({ key: 'k2', authUrl: 'https://auth/', poll: 'https://poll/k2', poll_rate_ms: 1000 });
106
+ const older = new pryv.Service('https://reg.test.local/service/info');
107
+ const envOld = await older.startAccessRequest(REQUEST);
108
+ expect(envOld.key).to.equal('k2');
109
+ expect(envOld).to.not.have.property('consent');
110
+ });
111
+ });
@@ -90,5 +90,30 @@ describe('[MFLX] Service MFA', function () {
90
90
  expect(err.id).to.equal('custom-id');
91
91
  expect(err.message).to.equal('custom msg');
92
92
  });
93
+
94
+ it('[MERC] surfaces the MFA method from the API body when present', function () {
95
+ const totp = new pryv.MfaRequiredError(
96
+ 'tok-1',
97
+ { status: 200 },
98
+ { mfaToken: 'tok-1', mfaMethod: 'totp' }
99
+ );
100
+ expect(totp.method).to.equal('totp');
101
+
102
+ const sms = new pryv.MfaRequiredError(
103
+ 'tok-2',
104
+ { status: 200 },
105
+ { mfaToken: 'tok-2', mfaMethod: 'sms' }
106
+ );
107
+ expect(sms.method).to.equal('sms');
108
+ });
109
+
110
+ it('[MERD] leaves method undefined when the body omits it (older servers)', function () {
111
+ const err = new pryv.MfaRequiredError(
112
+ 'tok-3',
113
+ { status: 200 },
114
+ { mfaToken: 'tok-3' }
115
+ );
116
+ expect(err.method).to.equal(undefined);
117
+ });
93
118
  });
94
119
  });
@@ -82,8 +82,8 @@ describe('[SERX] Service', function () {
82
82
  if (isDnsLess) {
83
83
  expect(error.message).to.include('Unknown user');
84
84
  } else {
85
- // fetch throws 'fetch failed'
86
- expect(error.message).to.match(/Load failed|fetch failed|getaddrinfo ENOTFOUND|Request has been terminated/);
85
+ // fetch error message differs per environment (Chrome says 'Failed to fetch')
86
+ expect(error.message).to.match(/Load failed|fetch failed|Failed to fetch|getaddrinfo ENOTFOUND|Request has been terminated/);
87
87
  }
88
88
  return;
89
89
  }
@@ -89,7 +89,10 @@ describe('[SSEC] shared-secrets helper', function () {
89
89
  const seen = new Set();
90
90
  for (let i = 0; i < 50; i++) {
91
91
  const out = await SharedSecrets.create(conn, {
92
- ttl: 60, title: 't', onConsumed: { message: 'm' }, secret: 1,
92
+ ttl: 60,
93
+ title: 't',
94
+ onConsumed: { message: 'm' },
95
+ secret: 1,
93
96
  signature: { type: 'hmac-sha256', verifierSecret: 'V' }
94
97
  });
95
98
  const { randomPart } = SharedSecrets.parseKey(out.key);
@@ -2,17 +2,16 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <title>Pryv JS lib browser tests</title>
5
- <link rel="stylesheet" href="https://unpkg.com/mocha/mocha.css" />
5
+ <link rel="stylesheet" href="https://unpkg.com/mocha@10.8.2/mocha.css" />
6
6
  </head>
7
7
 
8
8
  <body>
9
9
  <div id="mocha"></div>
10
10
  <script src="https://unpkg.com/chai@4.3.7/chai.js"></script>
11
- <script src="https://unpkg.com/mocha/mocha.js"></script>
11
+ <script src="https://unpkg.com/mocha@10.8.2/mocha.js"></script>
12
12
 
13
13
  <script class="mocha-init">
14
14
  mocha.setup('bdd');
15
- mocha.setup({ignoreLeaks: true});
16
15
  </script>
17
16
 
18
17
  <!-- Files cf. webpack config -->