pryv 3.11.0 → 3.12.1

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.11.0",
3
+ "version": "3.12.1",
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,16 @@ 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. a
257
+ // custom listener re-initializing to INITIALIZED on SIGNOUT; the LoginButton's
258
+ // own re-init now starts a microtask later) would otherwise overwrite
259
+ // `this._state`, so later listeners in this loop would receive the wrong state
260
+ // (a logout would deliver INITIALIZED instead of SIGNOUT to the app's
261
+ // onStateChange).
248
262
  this.stateChangeListeners.forEach((listener) => {
249
263
  try {
250
- listener(this.state);
264
+ listener(newState);
251
265
  } catch (e) {
252
266
  console.log('Error during set state ()', e);
253
267
  }
@@ -74,8 +74,11 @@ class LoginButton {
74
74
  case AuthStates.SIGNOUT: {
75
75
  const message = this.messages.SIGNOUT_CONFIRM ? this.messages.SIGNOUT_CONFIRM : 'Logout ?';
76
76
  if (confirm(message)) {
77
- this.deleteAuthorizationData();
78
- this.auth.init();
77
+ // Awaited: a re-init left running in the background resumes later
78
+ // (after loading assets) and consumes whatever poll URL the page shows
79
+ // by then, racing any other sign-in in progress.
80
+ await this.deleteAuthorizationData();
81
+ await this.auth.init();
79
82
  }
80
83
  break;
81
84
  }
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
  /**
@@ -552,8 +573,12 @@ class Service {
552
573
  * apiEndpoint), and uses this method to build a working `Connection`.
553
574
  *
554
575
  * The implementation polls `<access>/<key>` once; the call MUST be
555
- * made while the access is still in the ACCEPTED state (which
556
- * persists until expiry — see `expireAfter` on the access request).
576
+ * made while the access request is still readable in the ACCEPTED
577
+ * state. Servers keep a decided request only for a short retention
578
+ * window after it is first polled (default 2 minutes, operator setting
579
+ * `access.terminalRetentionMs`), so call this promptly after the flow
580
+ * completes; afterwards the key is unknown. (`expireAfter` on the
581
+ * access request is the lifetime of the access created, not of the key.)
557
582
  *
558
583
  * @param {string} key - polling key from `startAccessRequest`
559
584
  * @returns {Promise<Connection>}
@@ -96,6 +96,9 @@ async function hmacSha256Hex (verifierSecret, message) {
96
96
  */
97
97
  async function create (connection, params) {
98
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>} */
99
102
  const body = { ...rest };
100
103
  let key = null;
101
104
 
@@ -108,12 +111,12 @@ async function create (connection, params) {
108
111
  type: 'hmac-sha256',
109
112
  value: await hmacSha256Hex(signature.verifierSecret, random)
110
113
  };
111
- const res = await connection.post('shared-secrets', body);
112
- 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 };
113
116
  }
114
117
 
115
118
  if (signature != null) body.signature = signature;
116
- const res = await connection.post('shared-secrets', body);
119
+ const res = /** @type {any} */ (await connection.post('shared-secrets', body));
117
120
  key = res.sharedSecret.key;
118
121
  return { ...res.sharedSecret, key };
119
122
  }
@@ -149,7 +152,11 @@ async function retrieve (apiEndpoint, key, options = {}) {
149
152
  });
150
153
  const parsed = await res.json();
151
154
  if (!res.ok) {
152
- 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
+ );
153
160
  err.id = parsed?.error?.id;
154
161
  err.returnUrl = parsed?.error?.data?.returnUrl;
155
162
  throw err;
package/src/index.d.ts CHANGED
@@ -871,8 +871,16 @@ declare module 'pryv' {
871
871
  authUrl: string;
872
872
  poll: string;
873
873
  pollRateMs: number;
874
+ /** Echoed only by a core that understood `authRequest.consent`. */
875
+ consent?: AuthRequestConsentForm;
874
876
  }>;
875
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>;
876
884
 
877
885
  static buildAPIEndpoint(
878
886
  serviceInfo: ServiceInfo,
@@ -881,12 +889,74 @@ declare module 'pryv' {
881
889
  ): string;
882
890
  }
883
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
+
884
905
  export type AuthRequestedPermission = {
885
906
  streamId: Identifier;
886
907
  defaultName: string;
887
908
  level: PermissionLevel;
888
909
  };
889
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
+
890
960
  export type States =
891
961
  | 'ERROR'
892
962
  | 'LOADING'
@@ -919,6 +989,7 @@ declare module 'pryv' {
919
989
  level: PermissionLevel;
920
990
  defaultName: string;
921
991
  }>;
992
+ consent?: AuthRequestConsentForm;
922
993
  requestingAppId: string;
923
994
  returnUrl?: string | null;
924
995
  serviceInfo?: ServiceInfo;
@@ -956,6 +1027,7 @@ declare module 'pryv' {
956
1027
  poll_rate_ms: number;
957
1028
  requestingAppId: string;
958
1029
  requestedPermissions: AuthRequestedPermission[];
1030
+ consent?: AuthRequestConsentForm;
959
1031
  lang?: string;
960
1032
  returnURL?: string;
961
1033
  clientData?: KeyValue;
@@ -970,6 +1042,7 @@ declare module 'pryv' {
970
1042
  requestingAppId: string;
971
1043
  languageCode?: string;
972
1044
  requestedPermissions: AuthRequestedPermission[];
1045
+ consent?: AuthRequestConsent;
973
1046
  returnUrl?: string | boolean;
974
1047
  referer?: string;
975
1048
  clientData?: KeyValue;
@@ -1202,6 +1275,7 @@ declare module 'pryv' {
1202
1275
  MfaRequiredError: typeof MfaRequiredError;
1203
1276
  ERRORS: typeof ERRORS;
1204
1277
  version: version;
1278
+ connectFromKey: typeof connectFromKey;
1205
1279
  };
1206
1280
 
1207
1281
  export default pryv;
@@ -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,76 @@ 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
+ // Worst case of a LoginButton-like listener (registered after the external
116
+ // app listener): on SIGNOUT it re-initializes synchronously, driving the
117
+ // state to INITIALIZED during the same dispatch. (The real LoginButton
118
+ // awaits its re-init, which starts a microtask later.)
119
+ a.stateChangeListeners.push((s) => {
120
+ if (s.status === AuthStates.SIGNOUT) a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
121
+ });
122
+ a._state = { status: AuthStates.AUTHORIZED };
123
+ seen.length = 0;
124
+ await a.handleClick(); // AUTHORIZED -> SIGNOUT
125
+ expect(seen).to.include(AuthStates.SIGNOUT);
126
+ expect(seen[seen.length - 1]).to.equal(AuthStates.INITIALIZED);
127
+ });
128
+
129
+ it('[ACLO2] a listener re-entering the setter must not corrupt the state delivered to later listeners', function () {
130
+ // The setter must hand each listener the state dispatched to IT, not a value
131
+ // an earlier listener mutated mid-dispatch. Worst case: a re-entrant listener
132
+ // registered BEFORE the recorder.
133
+ const a = makeAuth(() => {});
134
+ const seen = [];
135
+ let reentered = false;
136
+ a.stateChangeListeners.push((s) => {
137
+ if (s.status === AuthStates.SIGNOUT && !reentered) {
138
+ reentered = true;
139
+ a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
140
+ }
141
+ });
142
+ a.stateChangeListeners.push((s) => seen.push(s.status));
143
+ seen.length = 0;
144
+ a.state = { status: AuthStates.SIGNOUT };
145
+ // The re-entrant INITIALIZED dispatch completes first (inner), then the outer
146
+ // SIGNOUT dispatch resumes to the recorder; the recorder MUST see SIGNOUT.
147
+ expect(seen).to.deep.equal([AuthStates.INITIALIZED, AuthStates.SIGNOUT]);
148
+ });
149
+
150
+ it('[ACLO3] init() registers the LoginButton listener only once, so logout confirms exactly once', async function () {
151
+ let confirms = 0;
152
+ const fakeButton = {
153
+ onStateChange: (s) => { if (s.status === AuthStates.SIGNOUT) confirms++; },
154
+ getAuthorizationData: () => null
155
+ };
156
+ const a = new AuthController({
157
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
158
+ }, service, fakeButton);
159
+ await a.init();
160
+ const nAfterFirst = a.stateChangeListeners.length;
161
+ await a.init(); // the confirmed-logout path re-inits the same controller
162
+ await a.init();
163
+ expect(a.stateChangeListeners.length).to.equal(nAfterFirst); // no compounding
164
+ confirms = 0;
165
+ a.state = { status: AuthStates.SIGNOUT }; // one dispatch -> one confirm
166
+ expect(confirms).to.equal(1);
167
+ });
168
+ });
95
169
  });
@@ -39,6 +39,7 @@ describe('[LBTX] LoginButton', function () {
39
39
  delete global.document;
40
40
  delete global.window;
41
41
  delete global.location;
42
+ delete global.navigator;
42
43
  delete global.confirm;
43
44
  });
44
45
 
@@ -174,6 +175,23 @@ describe('[LBTX] LoginButton', function () {
174
175
  // Confirm is mocked to return true
175
176
  });
176
177
 
178
+ it('[LBSG] SIGNOUT waits for the re-init to finish (none left running in the background)', async function () {
179
+ // A re-init that outlives onStateChange resumes later and consumes
180
+ // whatever poll URL the page shows by then (it raced [LBRU] on CI).
181
+ const originalInit = loginBtn.auth.init;
182
+ let reinitDone = false;
183
+ loginBtn.auth.init = async function () {
184
+ await new Promise((resolve) => setTimeout(resolve, 50));
185
+ reinitDone = true;
186
+ };
187
+ try {
188
+ await loginBtn.onStateChange({ status: AuthStates.SIGNOUT });
189
+ expect(reinitDone).to.equal(true);
190
+ } finally {
191
+ loginBtn.auth.init = originalInit;
192
+ }
193
+ });
194
+
177
195
  it('[LBSF] handles unknown state gracefully', async function () {
178
196
  // Should log warning but not throw
179
197
  await loginBtn.onStateChange({ status: 'UNKNOWN_STATE' });
@@ -9,14 +9,19 @@ const utils = require('../src/utils');
9
9
 
10
10
  describe('[LBRU] LoginButton URL cleanup after redirect', function () {
11
11
  const PAGE_URL = 'http://localhost/app?x=1&prYvpoll=http%3A%2F%2Flocalhost%2Fpoll%2Fkey123&prYvstatus=ACCEPTED';
12
- let dom;
13
- let cleanupDom = false;
12
+ const GLOBALS = ['document', 'window', 'location', 'navigator'];
13
+ let saved;
14
14
  let originalFetchGet;
15
15
 
16
16
  before(() => {
17
- if (typeof document === 'undefined') {
18
- cleanupDom = true;
19
- dom = new JSDOM('<!DOCTYPE html><body></body>', { url: PAGE_URL });
17
+ // Always our own page: reusing a DOM left by another file would test that
18
+ // page's URL, not PAGE_URL. Node only (a real browser run has no JSDOM).
19
+ if (typeof JSDOM !== 'undefined') {
20
+ saved = {};
21
+ for (const name of GLOBALS) {
22
+ saved[name] = Object.getOwnPropertyDescriptor(global, name);
23
+ }
24
+ const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: PAGE_URL });
20
25
  global.document = dom.window.document;
21
26
  global.window = dom.window;
22
27
  global.location = dom.window.location;
@@ -28,11 +33,11 @@ describe('[LBRU] LoginButton URL cleanup after redirect', function () {
28
33
 
29
34
  after(() => {
30
35
  utils.fetchGet = originalFetchGet;
31
- if (!cleanupDom) return;
32
- delete global.document;
33
- delete global.window;
34
- delete global.location;
35
- delete global.navigator;
36
+ if (saved == null) return;
37
+ for (const name of GLOBALS) {
38
+ if (saved[name]) Object.defineProperty(global, name, saved[name]);
39
+ else delete global[name];
40
+ }
36
41
  });
37
42
 
38
43
  it('[LBR1] strips consumed prYv* params from the visible URL', async () => {
@@ -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
+ });