pryv 3.4.1 → 3.6.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/package.json +1 -1
- package/src/Auth/AuthController.js +49 -1
- package/src/Connection.js +47 -8
- package/src/Service.js +46 -0
- package/src/index.d.ts +31 -2
- package/src/index.js +25 -3
- package/src/lib/StaleAccessIdError.js +3 -3
- package/src/lib/buildSearchParams.js +14 -4
- package/src/lib/resolveDotPath.js +25 -0
- package/src/utils.js +8 -8
- package/test/AuthController.test.js +74 -0
- package/test/Connection.test.js +1 -1
- package/test/ContentQueries.test.js +95 -0
- package/test/Service.accessRequest.test.js +24 -0
- package/test/utils.test.js +1 -1
package/package.json
CHANGED
|
@@ -22,8 +22,15 @@ class AuthController {
|
|
|
22
22
|
validateSettings.call(this, settings);
|
|
23
23
|
|
|
24
24
|
this.stateChangeListeners = [];
|
|
25
|
+
// External `onStateChange` callers only see `{ status, id, key, serviceInfo? }`
|
|
26
|
+
// on AUTHORIZED — credentials (`username`, `token`, `apiEndpoint`) stay
|
|
27
|
+
// inside the lib. Internal listeners (e.g. LoginButton, for cookie
|
|
28
|
+
// autologin) get the full unfiltered state.
|
|
25
29
|
if (this.settings.onStateChange) {
|
|
26
|
-
this.
|
|
30
|
+
const externalListener = this.settings.onStateChange;
|
|
31
|
+
this.stateChangeListeners.push(function (state) {
|
|
32
|
+
externalListener(filterForExternalListener(state));
|
|
33
|
+
});
|
|
27
34
|
}
|
|
28
35
|
this.service = service;
|
|
29
36
|
|
|
@@ -166,6 +173,10 @@ class AuthController {
|
|
|
166
173
|
async startAuthRequest () {
|
|
167
174
|
// @ts-ignore - postAccess uses .call(this) for context
|
|
168
175
|
this.state = await postAccess.call(this);
|
|
176
|
+
// Remember the polling key so listeners on the terminal AUTHORIZED
|
|
177
|
+
// state can be handed `{ key, serviceInfo? }` (the polling response
|
|
178
|
+
// itself doesn't echo `key` back).
|
|
179
|
+
this._authFlowKey = this.state?.key;
|
|
169
180
|
|
|
170
181
|
await doPolling.call(this);
|
|
171
182
|
|
|
@@ -205,6 +216,11 @@ class AuthController {
|
|
|
205
216
|
// @ts-ignore - this is bound via .call()
|
|
206
217
|
setTimeout(await doPolling.bind(this), this.state?.poll_rate_ms);
|
|
207
218
|
} else {
|
|
219
|
+
// Carry the key forward — listeners on the narrow public surface
|
|
220
|
+
// need it, and the server doesn't echo it back on ACCEPTED.
|
|
221
|
+
if (this._authFlowKey != null && pollResponse.key == null) {
|
|
222
|
+
pollResponse.key = this._authFlowKey;
|
|
223
|
+
}
|
|
208
224
|
this.state = pollResponse;
|
|
209
225
|
}
|
|
210
226
|
|
|
@@ -245,6 +261,38 @@ class AuthController {
|
|
|
245
261
|
|
|
246
262
|
// ----------- private methods -------------
|
|
247
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Narrow the state passed to *external* `onStateChange` callers so the
|
|
266
|
+
* calling app sees only `{ status, id, key, serviceInfo? }` on the
|
|
267
|
+
* terminal AUTHORIZED state reached through the auth-flow polling path.
|
|
268
|
+
* `username` / `token` / `apiEndpoint` are kept inside the lib; the
|
|
269
|
+
* calling app uses `pryv.connectFromKey(key, serviceInfoUrl)` to obtain
|
|
270
|
+
* a `Connection`.
|
|
271
|
+
*
|
|
272
|
+
* The cookie-autologin path (no fresh `key` available, restored from
|
|
273
|
+
* `LoginButton.getAuthorizationData()`) passes through unchanged so
|
|
274
|
+
* existing pages that build a `Connection` directly from the restored
|
|
275
|
+
* state on page load keep working.
|
|
276
|
+
*
|
|
277
|
+
* Non-AUTHORIZED states pass through unchanged so error messages /
|
|
278
|
+
* loading flags / etc. still reach the listener.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} state - full internal state
|
|
281
|
+
* @returns {Object} narrowed state
|
|
282
|
+
*/
|
|
283
|
+
function filterForExternalListener (state) {
|
|
284
|
+
if (state == null || state.status !== AuthStates.AUTHORIZED) {
|
|
285
|
+
return state;
|
|
286
|
+
}
|
|
287
|
+
// No key → cookie-autologin path; preserve existing shape.
|
|
288
|
+
if (state.key == null) {
|
|
289
|
+
return state;
|
|
290
|
+
}
|
|
291
|
+
const out = { status: state.status, id: state.id, key: state.key };
|
|
292
|
+
if (state.serviceInfo != null) out.serviceInfo = state.serviceInfo;
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
248
296
|
async function checkAutoLogin (authController) {
|
|
249
297
|
const loginButton = authController.loginButton;
|
|
250
298
|
if (loginButton == null) {
|
package/src/Connection.js
CHANGED
|
@@ -8,6 +8,7 @@ const libGetEventStreamed = require('./lib/getEventStreamed');
|
|
|
8
8
|
const PryvError = require('./lib/PryvError');
|
|
9
9
|
const StaleAccessIdError = require('./lib/StaleAccessIdError');
|
|
10
10
|
const buildSearchParams = require('./lib/buildSearchParams');
|
|
11
|
+
const resolveDotPath = require('./lib/resolveDotPath');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* @class Connection
|
|
@@ -77,8 +78,8 @@ class Connection {
|
|
|
77
78
|
* caches the result; subsequent calls return the cached copy in O(1).
|
|
78
79
|
* Pass `forceRefresh: true` to invalidate the cache and fetch a fresh
|
|
79
80
|
* copy from the server — used internally by `connection.socket` to
|
|
80
|
-
* react to
|
|
81
|
-
*
|
|
81
|
+
* react to `accessUpdated` server-push events. A failed server
|
|
82
|
+
* fetch leaves any prior cached value intact.
|
|
82
83
|
*
|
|
83
84
|
* @param {boolean} [forceRefresh=false] - bypass + refresh the cache
|
|
84
85
|
* @returns {Promise<AccessInfo>} Promise resolving to the access info
|
|
@@ -350,6 +351,44 @@ class Connection {
|
|
|
350
351
|
return res.body;
|
|
351
352
|
}
|
|
352
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Get the latest event per value for a content path — typical form-prefill
|
|
356
|
+
* lookup ("latest assertion per code"). Queries `events.get` with a
|
|
357
|
+
* `content` condition `{ path, in: values }` (server must support content
|
|
358
|
+
* queries — see `Service.supportsContentQueries()`), pages through the
|
|
359
|
+
* time-descending result and keeps the first (= latest) event per value.
|
|
360
|
+
* Handles paging internally, so the result is correct regardless of the
|
|
361
|
+
* default `events.get` page size.
|
|
362
|
+
* @param {string} path - dot-path into `content` (or `$` for the root value)
|
|
363
|
+
* @param {Array<string|number|boolean>} values - values to look up (one Map entry max per value)
|
|
364
|
+
* @param {Object} [baseQuery] - additional `events.get` params (e.g. `streams`, `types`, `fromTime`); passed through
|
|
365
|
+
* @returns {Promise<Map<string|number|boolean, Object>>} value → latest matching event; values with no match are absent
|
|
366
|
+
*/
|
|
367
|
+
async getLatestByContent (path, values, baseQuery = {}) {
|
|
368
|
+
const PAGE_LIMIT = 1000;
|
|
369
|
+
const lookup = new Set(values);
|
|
370
|
+
const found = new Map();
|
|
371
|
+
const condition = { path, in: [...lookup] };
|
|
372
|
+
const content = (baseQuery.content || []).concat([condition]);
|
|
373
|
+
let skip = 0;
|
|
374
|
+
while (found.size < lookup.size) {
|
|
375
|
+
const params = Object.assign({}, baseQuery, {
|
|
376
|
+
content,
|
|
377
|
+
sortAscending: false,
|
|
378
|
+
skip,
|
|
379
|
+
limit: PAGE_LIMIT
|
|
380
|
+
});
|
|
381
|
+
const events = await this.apiOne('events.get', params, 'events');
|
|
382
|
+
for (const event of events) {
|
|
383
|
+
const value = resolveDotPath(event.content, path);
|
|
384
|
+
if (lookup.has(value) && !found.has(value)) found.set(value, event);
|
|
385
|
+
}
|
|
386
|
+
if (events.length < PAGE_LIMIT) break;
|
|
387
|
+
skip += events.length;
|
|
388
|
+
}
|
|
389
|
+
return found;
|
|
390
|
+
}
|
|
391
|
+
|
|
353
392
|
/**
|
|
354
393
|
* Create an event with attached file
|
|
355
394
|
* NODE.jS ONLY
|
|
@@ -429,10 +468,10 @@ class Connection {
|
|
|
429
468
|
}
|
|
430
469
|
|
|
431
470
|
/**
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* into a typed `StaleAccessIdError` so callers can `instanceof`-test
|
|
435
|
-
* refetch + retry without re-parsing the inner error.
|
|
471
|
+
* Update an access by composite id (Pryv.io ≥ 2.0.0-pre.X). Wraps
|
|
472
|
+
* `accesses.update` and translates the 409 `stale-resource` response
|
|
473
|
+
* into a typed `StaleAccessIdError` so callers can `instanceof`-test
|
|
474
|
+
* and refetch + retry without re-parsing the inner error.
|
|
436
475
|
*
|
|
437
476
|
* Pass `id` as the wire-format reference returned by the server — bare
|
|
438
477
|
* cuid on a never-updated access, composite `<base>:<serial>` otherwise.
|
|
@@ -456,8 +495,8 @@ class Connection {
|
|
|
456
495
|
}
|
|
457
496
|
|
|
458
497
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
498
|
+
* Fetch an access by composite id including its full version history
|
|
499
|
+
* (oldest first). Server: `accesses.getOne ?includeHistory=true`.
|
|
461
500
|
*
|
|
462
501
|
* Useful for audit views. Pass the composite `<base>:<serial>` to
|
|
463
502
|
* inspect a specific past version (the result's `current` field then
|
package/src/Service.js
CHANGED
|
@@ -80,6 +80,17 @@ class Service {
|
|
|
80
80
|
return (infos.features == null || infos.features.noHF !== true);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Whether the platform supports `events.get` content/clientData query
|
|
85
|
+
* conditions (`features.contentQueries` in service info). Older platforms
|
|
86
|
+
* reject the parameters with a 400 — use this to pick a fallback path.
|
|
87
|
+
* @returns {Promise<boolean>}
|
|
88
|
+
*/
|
|
89
|
+
async supportsContentQueries () {
|
|
90
|
+
const infos = await this.info();
|
|
91
|
+
return infos.features != null && infos.features.contentQueries === true;
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
/**
|
|
84
95
|
* Check if a service has username in the hostname or in the path of the API.
|
|
85
96
|
* @returns {Promise<boolean>} Promise resolving to true if the service does not rely on DNS to find a host related to a username
|
|
@@ -521,6 +532,40 @@ class Service {
|
|
|
521
532
|
return body;
|
|
522
533
|
}
|
|
523
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Resolve a completed auth-flow polling key into a `Connection`.
|
|
537
|
+
*
|
|
538
|
+
* Pairs with `Service.startAccessRequest` / `Service.pollAccessRequest`
|
|
539
|
+
* and the headless polling pattern: the calling app holds only the
|
|
540
|
+
* `key` returned by the auth-flow (not the underlying token /
|
|
541
|
+
* apiEndpoint), and uses this method to build a working `Connection`.
|
|
542
|
+
*
|
|
543
|
+
* The implementation polls `<access>/<key>` once; the call MUST be
|
|
544
|
+
* made while the access is still in the ACCEPTED state (which
|
|
545
|
+
* persists until expiry — see `expireAfter` on the access request).
|
|
546
|
+
*
|
|
547
|
+
* @param {string} key - polling key from `startAccessRequest`
|
|
548
|
+
* @returns {Promise<Connection>}
|
|
549
|
+
* @throws {PryvError} if the key is not ACCEPTED (NEED_SIGNIN, REFUSED, ERROR)
|
|
550
|
+
*/
|
|
551
|
+
async connectFromKey (key) {
|
|
552
|
+
if (!key) {
|
|
553
|
+
throw new PryvError('connectFromKey requires a key');
|
|
554
|
+
}
|
|
555
|
+
const body = await this.pollAccessRequest(key);
|
|
556
|
+
if (body.status !== 'ACCEPTED') {
|
|
557
|
+
throw new PryvError(
|
|
558
|
+
'connectFromKey: access is not ACCEPTED (status=' + body.status + ')'
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (!body.apiEndpoint) {
|
|
562
|
+
throw new PryvError(
|
|
563
|
+
'connectFromKey: ACCEPTED response missing apiEndpoint'
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
return new Connection(body.apiEndpoint, this);
|
|
567
|
+
}
|
|
568
|
+
|
|
524
569
|
/**
|
|
525
570
|
* Set a new password using a reset token (from the reset email).
|
|
526
571
|
* Pre-auth — no login token required.
|
|
@@ -588,4 +633,5 @@ const Connection = require('./Connection');
|
|
|
588
633
|
* @property {string} [assets.definitions] URL to json object with assets definitions
|
|
589
634
|
* @property {Object} [features] Platform feature flags
|
|
590
635
|
* @property {boolean} [features.noHF] True if HF data is not supported
|
|
636
|
+
* @property {boolean} [features.contentQueries] True if events.get content/clientData query conditions are supported
|
|
591
637
|
*/
|
package/src/index.d.ts
CHANGED
|
@@ -267,6 +267,22 @@ declare module 'pryv' {
|
|
|
267
267
|
not?: Identifier[];
|
|
268
268
|
};
|
|
269
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Condition on a JSON path of events' `content` or `clientData`.
|
|
272
|
+
* `path` is a dot-path (segments: [a-zA-Z0-9_:-]) or `$` for the root
|
|
273
|
+
* value; exactly one operator per condition. Conditions AND together.
|
|
274
|
+
* Server support is advertised by `features.contentQueries` (see
|
|
275
|
+
* `Service.supportsContentQueries()`).
|
|
276
|
+
*/
|
|
277
|
+
export type ContentQueryCondition = { path: string } & (
|
|
278
|
+
| { eq: string | number | boolean }
|
|
279
|
+
| { neq: string | number | boolean }
|
|
280
|
+
| { in: Array<string | number | boolean> }
|
|
281
|
+
| { exists: boolean }
|
|
282
|
+
| { gt: number } | { gte: number } | { lt: number } | { lte: number }
|
|
283
|
+
| { prefix: string }
|
|
284
|
+
);
|
|
285
|
+
|
|
270
286
|
export type EventQueryParams = {
|
|
271
287
|
fromTime: Timestamp;
|
|
272
288
|
toTime: Timestamp;
|
|
@@ -280,6 +296,8 @@ declare module 'pryv' {
|
|
|
280
296
|
state: 'default' | 'trashed' | 'all';
|
|
281
297
|
modifiedSince: Timestamp;
|
|
282
298
|
includeDeletion: boolean;
|
|
299
|
+
content: ContentQueryCondition[];
|
|
300
|
+
clientData: ContentQueryCondition[];
|
|
283
301
|
};
|
|
284
302
|
|
|
285
303
|
export type EventQueryParamsStreamQuery = Omit<EventQueryParams, 'streams'> & {
|
|
@@ -705,6 +723,15 @@ declare module 'pryv' {
|
|
|
705
723
|
queryParams: Partial<EventQueryParamsStreamQuery>,
|
|
706
724
|
forEachEvent: StreamedEventsHandler,
|
|
707
725
|
): Promise<StreamedEventsResult>;
|
|
726
|
+
/**
|
|
727
|
+
* Latest event per value for a content path (form-prefill lookup).
|
|
728
|
+
* Pages internally; requires server content-query support.
|
|
729
|
+
*/
|
|
730
|
+
getLatestByContent(
|
|
731
|
+
path: string,
|
|
732
|
+
values: Array<string | number | boolean>,
|
|
733
|
+
baseQuery?: Partial<EventQueryParamsStreamQuery>,
|
|
734
|
+
): Promise<Map<string | number | boolean, Event>>;
|
|
708
735
|
createEventWithFile(
|
|
709
736
|
params: EventFileCreationParams,
|
|
710
737
|
filePath: string | Buffer | Blob,
|
|
@@ -725,9 +752,9 @@ declare module 'pryv' {
|
|
|
725
752
|
): Promise<HFSeriesAddResult>;
|
|
726
753
|
/** Memoized; pass `forceRefresh: true` to bypass + refresh the cache. */
|
|
727
754
|
accessInfo(forceRefresh?: boolean): Promise<AccessInfo>;
|
|
728
|
-
/**
|
|
755
|
+
/** Update an access by composite id. */
|
|
729
756
|
updateAccess(id: string, changes: object): Promise<object>;
|
|
730
|
-
/**
|
|
757
|
+
/** Fetch an access including its full version history. */
|
|
731
758
|
getAccessWithHistory(id: string): Promise<{ access: object, current?: string, history?: object[] }>;
|
|
732
759
|
revoke(throwOnFail?: boolean, usingConnection?: Connection): Promise<any>;
|
|
733
760
|
readonly deltaTime: number;
|
|
@@ -826,6 +853,8 @@ declare module 'pryv' {
|
|
|
826
853
|
): Promise<Connection>;
|
|
827
854
|
|
|
828
855
|
supportsHF(): Promise<boolean>;
|
|
856
|
+
/** Whether the platform supports events.get content/clientData query conditions. */
|
|
857
|
+
supportsContentQueries(): Promise<boolean>;
|
|
829
858
|
isDnsLess(): Promise<boolean>;
|
|
830
859
|
|
|
831
860
|
userExists(userId: string): Promise<boolean>;
|
package/src/index.js
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* @property {pryv.utils} utils - Exposes some utils for HTTP calls and tools to manipulate Pryv's API endpoints
|
|
12
12
|
* @property {pryv.PryvError} PryvError - Custom error class with innerObject + structured API-error fields
|
|
13
13
|
* @property {pryv.MfaRequiredError} MfaRequiredError - Thrown by Service.login when the platform returns an mfaToken instead of a token. Carries `.mfaToken`.
|
|
14
|
-
* @property {pryv.StaleAccessIdError} StaleAccessIdError -
|
|
14
|
+
* @property {pryv.StaleAccessIdError} StaleAccessIdError - Thrown when a Pryv.io server rejects an `accesses.update` / `accesses.delete` with a 409 stale-resource. Refetch + retry.
|
|
15
15
|
* @property {Object} ERRORS - Catalogue of Pryv API error ids (mirrors open-pryv.io/components/errors)
|
|
16
16
|
*/
|
|
17
|
+
const Service = require('./Service');
|
|
18
|
+
|
|
17
19
|
module.exports = {
|
|
18
|
-
Service
|
|
20
|
+
Service,
|
|
19
21
|
Connection: require('./Connection'),
|
|
20
22
|
Auth: require('./Auth'),
|
|
21
23
|
Browser: require('./Browser'),
|
|
@@ -24,5 +26,25 @@ module.exports = {
|
|
|
24
26
|
MfaRequiredError: require('./lib/MfaRequiredError'),
|
|
25
27
|
StaleAccessIdError: require('./lib/StaleAccessIdError'),
|
|
26
28
|
ERRORS: require('./lib/errorIds'),
|
|
27
|
-
version: require('../package.json').version
|
|
29
|
+
version: require('../package.json').version,
|
|
30
|
+
connectFromKey
|
|
28
31
|
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Module-level convenience over `Service#connectFromKey` — builds a
|
|
35
|
+
* `Service` on the fly, fetches its info, and resolves the given
|
|
36
|
+
* auth-flow polling key into a working `Connection`.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors the `pryv.connectFromKey(key, serviceInfoUrl)` shape the
|
|
39
|
+
* headless polling pattern documents.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} key - polling key from `Service.startAccessRequest`
|
|
42
|
+
* @param {string} serviceInfoUrl - URL of the platform's `/service/info`
|
|
43
|
+
* @param {Object} [serviceCustomizations] - same shape as `new Service(url, customizations)`
|
|
44
|
+
* @returns {Promise<import('./Connection')>}
|
|
45
|
+
*/
|
|
46
|
+
async function connectFromKey (key, serviceInfoUrl, serviceCustomizations) {
|
|
47
|
+
const service = new Service(serviceInfoUrl, serviceCustomizations);
|
|
48
|
+
await service.info();
|
|
49
|
+
return service.connectFromKey(key);
|
|
50
|
+
}
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
const PryvError = require('./PryvError');
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Typed error surfaced when a Pryv.io server (≥ 2.0.0-pre.X) rejects
|
|
10
|
+
* an `accesses.update` or `accesses.delete` call with a 409
|
|
11
|
+
* `stale-resource` response.
|
|
12
12
|
*
|
|
13
13
|
* The composite access id `<base>:<serial>` carries the version the
|
|
14
14
|
* caller last observed. If the access has since been updated, the
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Build URL search params string from an object, properly handling arrays
|
|
8
|
-
*
|
|
7
|
+
* Build URL search params string from an object, properly handling arrays
|
|
8
|
+
* and structured values.
|
|
9
|
+
* - Arrays of scalars are expanded as repeated keys: { a: ['x', 'y'] } => 'a=x&a=y'
|
|
10
|
+
* - Arrays containing objects (e.g. `content` / `clientData` conditions,
|
|
11
|
+
* rich `streams` queries) and plain objects are sent as one
|
|
12
|
+
* JSON-encoded parameter, which the API parses back.
|
|
9
13
|
* @param {Object} params - Query parameters object
|
|
10
14
|
* @returns {string} - URL encoded query string
|
|
11
15
|
*/
|
|
@@ -13,9 +17,15 @@ function buildSearchParams (params) {
|
|
|
13
17
|
const searchParams = new URLSearchParams();
|
|
14
18
|
for (const [key, value] of Object.entries(params)) {
|
|
15
19
|
if (Array.isArray(value)) {
|
|
16
|
-
|
|
17
|
-
searchParams.append(key,
|
|
20
|
+
if (value.some((item) => item !== null && typeof item === 'object')) {
|
|
21
|
+
searchParams.append(key, JSON.stringify(value));
|
|
22
|
+
} else {
|
|
23
|
+
for (const item of value) {
|
|
24
|
+
searchParams.append(key, item);
|
|
25
|
+
}
|
|
18
26
|
}
|
|
27
|
+
} else if (value !== null && typeof value === 'object') {
|
|
28
|
+
searchParams.append(key, JSON.stringify(value));
|
|
19
29
|
} else if (value !== undefined && value !== null) {
|
|
20
30
|
searchParams.append(key, value);
|
|
21
31
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a content-query dot-path against a JSON value.
|
|
8
|
+
* `$` addresses the root value itself. Returns `undefined` when the path
|
|
9
|
+
* does not lead to a value.
|
|
10
|
+
* @param {*} root - The JSON value (typically an event's `content`)
|
|
11
|
+
* @param {string} path - Dot-path (e.g. 'drug.codes.atc') or '$'
|
|
12
|
+
* @returns {*} The value at the path, or undefined
|
|
13
|
+
*/
|
|
14
|
+
function resolveDotPath (root, path) {
|
|
15
|
+
if (path === '$') return root;
|
|
16
|
+
let current = root;
|
|
17
|
+
for (const segment of path.split('.')) {
|
|
18
|
+
if (current == null || typeof current !== 'object' || Array.isArray(current)) return undefined;
|
|
19
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;
|
|
20
|
+
current = current[segment];
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = resolveDotPath;
|
package/src/utils.js
CHANGED
|
@@ -226,11 +226,11 @@ const utils = module.exports = {
|
|
|
226
226
|
},
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
229
|
+
* Parse a wire-format access reference into `{ base, serial }`
|
|
230
|
+
* (Pryv.io ≥ 2.0.0-pre.X). Accepts both bare cuid (`"abc123"` →
|
|
231
|
+
* `{ base: 'abc123', serial: null }`) and composite (`"abc123:3"` →
|
|
232
|
+
* `{ base: 'abc123', serial: 3 }`). Throws on malformed input.
|
|
233
|
+
* Apply this to `access.id`, `access.createdBy`,
|
|
234
234
|
* `access.modifiedBy`, and `streamIds` entries of the form
|
|
235
235
|
* `access-<base>:<serial>` from audit events.
|
|
236
236
|
* @memberof pryv.utils
|
|
@@ -256,9 +256,9 @@ const utils = module.exports = {
|
|
|
256
256
|
},
|
|
257
257
|
|
|
258
258
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
259
|
+
* Render an `{ base, serial }` pair back to the wire format. Bare
|
|
260
|
+
* cuid when serial is null/undefined; `<base>:<serial>` otherwise.
|
|
261
|
+
* Mostly used to construct the composite id when calling
|
|
262
262
|
* `connection.api()` for `accesses.update` / `accesses.delete`.
|
|
263
263
|
* @memberof pryv.utils
|
|
264
264
|
* @param {{ base: string, serial?: number | null }} ref
|
|
@@ -190,4 +190,78 @@ describe('[ACNX] AuthController', function () {
|
|
|
190
190
|
expect(auth.state.id).to.equal(AuthStates.LOADING); // retro-compatibility
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
describe('[AFLT] External listener filtering', function () {
|
|
195
|
+
it('[AFLA] AUTHORIZED state passes only { status, id, key, serviceInfo } to onStateChange', function () {
|
|
196
|
+
const seen = [];
|
|
197
|
+
const auth = new AuthController({
|
|
198
|
+
authRequest: {
|
|
199
|
+
requestingAppId: 'test-app',
|
|
200
|
+
requestedPermissions: []
|
|
201
|
+
},
|
|
202
|
+
onStateChange: (state) => seen.push(state)
|
|
203
|
+
}, service);
|
|
204
|
+
|
|
205
|
+
auth.state = {
|
|
206
|
+
status: AuthStates.AUTHORIZED,
|
|
207
|
+
key: 'abc123',
|
|
208
|
+
serviceInfo: { name: 'Test' },
|
|
209
|
+
apiEndpoint: 'https://token@example.com/',
|
|
210
|
+
username: 'alice',
|
|
211
|
+
token: 'secret-token'
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const last = seen[seen.length - 1];
|
|
215
|
+
expect(last.status).to.equal(AuthStates.AUTHORIZED);
|
|
216
|
+
expect(last.id).to.equal(AuthStates.AUTHORIZED);
|
|
217
|
+
expect(last.key).to.equal('abc123');
|
|
218
|
+
expect(last.serviceInfo).to.deep.equal({ name: 'Test' });
|
|
219
|
+
// Credentials must not leak to the calling app.
|
|
220
|
+
expect(last.apiEndpoint).to.equal(undefined);
|
|
221
|
+
expect(last.username).to.equal(undefined);
|
|
222
|
+
expect(last.token).to.equal(undefined);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('[AFLC] AUTHORIZED state from cookie-autologin (no key) passes through unchanged', function () {
|
|
226
|
+
// Mirrors `checkAutoLogin()`'s state shape: it spreads stored
|
|
227
|
+
// credentials into the AUTHORIZED state without a key, since the
|
|
228
|
+
// key is not persisted.
|
|
229
|
+
const seen = [];
|
|
230
|
+
const auth = new AuthController({
|
|
231
|
+
authRequest: {
|
|
232
|
+
requestingAppId: 'test-app',
|
|
233
|
+
requestedPermissions: []
|
|
234
|
+
},
|
|
235
|
+
onStateChange: (state) => seen.push(state)
|
|
236
|
+
}, service);
|
|
237
|
+
|
|
238
|
+
auth.state = {
|
|
239
|
+
status: AuthStates.AUTHORIZED,
|
|
240
|
+
apiEndpoint: 'https://token@example.com/',
|
|
241
|
+
username: 'alice'
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const last = seen[seen.length - 1];
|
|
245
|
+
// Backwards-compat: existing pages building Connection directly
|
|
246
|
+
// from `state.apiEndpoint` on page reload keep working.
|
|
247
|
+
expect(last.apiEndpoint).to.equal('https://token@example.com/');
|
|
248
|
+
expect(last.username).to.equal('alice');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('[AFLB] non-AUTHORIZED states pass through unchanged', function () {
|
|
252
|
+
const seen = [];
|
|
253
|
+
const auth = new AuthController({
|
|
254
|
+
authRequest: {
|
|
255
|
+
requestingAppId: 'test-app',
|
|
256
|
+
requestedPermissions: []
|
|
257
|
+
},
|
|
258
|
+
onStateChange: (state) => seen.push(state)
|
|
259
|
+
}, service);
|
|
260
|
+
|
|
261
|
+
auth.state = { status: AuthStates.ERROR, message: 'boom', error: new Error('e') };
|
|
262
|
+
const last = seen[seen.length - 1];
|
|
263
|
+
expect(last.status).to.equal(AuthStates.ERROR);
|
|
264
|
+
expect(last.message).to.equal('boom');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
193
267
|
});
|
package/test/Connection.test.js
CHANGED
|
@@ -500,7 +500,7 @@ describe('[CONX] Connection', () => {
|
|
|
500
500
|
expect(newUser.access.token).to.equal(accessInfoUser.token);
|
|
501
501
|
});
|
|
502
502
|
|
|
503
|
-
//
|
|
503
|
+
// accessInfo caching + forceRefresh.
|
|
504
504
|
|
|
505
505
|
it('[CAIC] accessInfo() memoizes — second call returns the same object reference', async () => {
|
|
506
506
|
const regexAPIandToken = /(.+):\/\/(.+)/gm;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect, pryv */
|
|
6
|
+
|
|
7
|
+
const { createId: cuid } = require('@paralleldrive/cuid2');
|
|
8
|
+
const testData = require('../../../test/test-data');
|
|
9
|
+
|
|
10
|
+
const buildSearchParams = require('../src/lib/buildSearchParams');
|
|
11
|
+
const resolveDotPath = require('../src/lib/resolveDotPath');
|
|
12
|
+
|
|
13
|
+
describe('[CQLJ] Content queries', () => {
|
|
14
|
+
describe('[CQLU] buildSearchParams structured values', () => {
|
|
15
|
+
it('[LU01] keeps scalar arrays as repeated keys', () => {
|
|
16
|
+
expect(buildSearchParams({ a: ['x', 'y'], b: 1 })).to.equal('a=x&a=y&b=1');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('[LU02] JSON-encodes arrays of objects (conditions) as one parameter', () => {
|
|
20
|
+
const conditions = [{ path: 'drug.codes.atc', eq: 'G03DA04' }];
|
|
21
|
+
const out = buildSearchParams({ content: conditions });
|
|
22
|
+
expect(decodeURIComponent(out)).to.equal('content=' + JSON.stringify(conditions));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('[LU03] JSON-encodes plain object values', () => {
|
|
26
|
+
const out = buildSearchParams({ streams: { any: ['a'] } });
|
|
27
|
+
expect(decodeURIComponent(out.replace(/\+/g, ' '))).to.equal('streams=' + JSON.stringify({ any: ['a'] }));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('[CQLR] resolveDotPath', () => {
|
|
32
|
+
it('[LR01] resolves nested paths, root $ and misses', () => {
|
|
33
|
+
const content = { drug: { codes: { atc: 'G03DA04' } }, taken: true };
|
|
34
|
+
expect(resolveDotPath(content, 'drug.codes.atc')).to.equal('G03DA04');
|
|
35
|
+
expect(resolveDotPath(content, 'taken')).to.equal(true);
|
|
36
|
+
expect(resolveDotPath(content, 'drug.codes.snomed')).to.equal(undefined);
|
|
37
|
+
expect(resolveDotPath(14.2, '$')).to.equal(14.2);
|
|
38
|
+
expect(resolveDotPath(undefined, 'a.b')).to.equal(undefined);
|
|
39
|
+
expect(resolveDotPath({ a: [1] }, 'a.0')).to.equal(undefined); // no array indices
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('[CQLI] against a live platform (skipped when unsupported)', () => {
|
|
44
|
+
let conn, supported, streamId;
|
|
45
|
+
const codes = { progesterone: 'G03DA04', aspirin: 'B01AC06', missing: 'N02BE01' };
|
|
46
|
+
|
|
47
|
+
before(async function () {
|
|
48
|
+
this.timeout(15000);
|
|
49
|
+
await testData.prepare();
|
|
50
|
+
conn = new pryv.Connection(testData.apiEndpointWithToken);
|
|
51
|
+
supported = await conn.service.supportsContentQueries();
|
|
52
|
+
if (!supported) return;
|
|
53
|
+
|
|
54
|
+
streamId = 'cq-' + cuid().substring(0, 8);
|
|
55
|
+
const calls = [{ method: 'streams.create', params: { id: streamId, name: 'CQ ' + streamId, parentId: 'data' } }];
|
|
56
|
+
// two assertions per drug — older taken:false, newer taken:true — to verify "latest"
|
|
57
|
+
let time = 1700000000;
|
|
58
|
+
for (const code of [codes.progesterone, codes.aspirin]) {
|
|
59
|
+
calls.push({ method: 'events.create', params: { streamIds: [streamId], type: 'medication/exposure-assertion-v1', time: time++, content: { drug: { codes: { atc: code } }, taken: false } } });
|
|
60
|
+
calls.push({ method: 'events.create', params: { streamIds: [streamId], type: 'medication/exposure-assertion-v1', time: time++, content: { drug: { codes: { atc: code } }, taken: true } } });
|
|
61
|
+
}
|
|
62
|
+
const res = await conn.api(calls);
|
|
63
|
+
for (const r of res) expect(r.error).to.be.undefined;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('[LI01] supportsContentQueries returns a boolean', async () => {
|
|
67
|
+
expect(typeof supported).to.equal('boolean');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('[LI02] events.get accepts content conditions via GET (streamed)', async function () {
|
|
71
|
+
if (!supported) this.skip();
|
|
72
|
+
const collected = [];
|
|
73
|
+
await conn.getEventsStreamed(
|
|
74
|
+
{ streams: [streamId], content: [{ path: 'drug.codes.atc', eq: codes.progesterone }, { path: 'taken', eq: true }] },
|
|
75
|
+
(event) => collected.push(event)
|
|
76
|
+
);
|
|
77
|
+
expect(collected.length).to.equal(1);
|
|
78
|
+
expect(collected[0].content.taken).to.equal(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('[LI03] getLatestByContent returns the latest event per value', async function () {
|
|
82
|
+
if (!supported) this.skip();
|
|
83
|
+
const byCode = await conn.getLatestByContent(
|
|
84
|
+
'drug.codes.atc',
|
|
85
|
+
[codes.progesterone, codes.aspirin, codes.missing],
|
|
86
|
+
{ streams: [streamId] }
|
|
87
|
+
);
|
|
88
|
+
expect(byCode.size).to.equal(2);
|
|
89
|
+
expect(byCode.get(codes.missing)).to.be.undefined;
|
|
90
|
+
// latest per code is the taken:true one (created later)
|
|
91
|
+
expect(byCode.get(codes.progesterone).content.taken).to.equal(true);
|
|
92
|
+
expect(byCode.get(codes.aspirin).content.taken).to.equal(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -38,6 +38,30 @@ describe('[ARQX] Service access-request init', function () {
|
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
describe('[CFKX] Service.connectFromKey', function () {
|
|
42
|
+
it('[CFKA] rejects when key is missing', async function () {
|
|
43
|
+
let caught;
|
|
44
|
+
try { await service.connectFromKey(); } catch (e) { caught = e; }
|
|
45
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('[CFKB] throws when the access is still NEED_SIGNIN', async function () {
|
|
49
|
+
this.timeout(15000);
|
|
50
|
+
const env = await service.startAccessRequest({
|
|
51
|
+
requestingAppId: 'jslib-test',
|
|
52
|
+
requestedPermissions: [{
|
|
53
|
+
streamId: 'data',
|
|
54
|
+
level: 'read',
|
|
55
|
+
defaultName: 'Test'
|
|
56
|
+
}]
|
|
57
|
+
});
|
|
58
|
+
let caught;
|
|
59
|
+
try { await service.connectFromKey(env.key); } catch (e) { caught = e; }
|
|
60
|
+
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
61
|
+
expect(caught.message).to.include('NEED_SIGNIN');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
41
65
|
describe('[APRX] Service.pollAccessRequest', function () {
|
|
42
66
|
it('[APRA] rejects when key is missing', async function () {
|
|
43
67
|
let caught;
|
package/test/utils.test.js
CHANGED
|
@@ -60,7 +60,7 @@ describe('[UTLX] utils', function () {
|
|
|
60
60
|
done();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
//
|
|
63
|
+
// Composite access references.
|
|
64
64
|
|
|
65
65
|
it('[UTLF] parseAccessRef on a bare cuid returns { base, serial: null }', function () {
|
|
66
66
|
const ref = pryv.utils.parseAccessRef('abc123def456');
|