pryv 3.0.4 → 3.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.0.4",
3
+ "version": "3.2.0",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
package/src/Connection.js CHANGED
@@ -6,6 +6,7 @@ const utils = require('./utils.js');
6
6
  const jsonParser = require('./lib/json-parser');
7
7
  const libGetEventStreamed = require('./lib/getEventStreamed');
8
8
  const PryvError = require('./lib/PryvError');
9
+ const StaleAccessIdError = require('./lib/StaleAccessIdError');
9
10
  const buildSearchParams = require('./lib/buildSearchParams');
10
11
 
11
12
  /**
@@ -71,11 +72,22 @@ class Connection {
71
72
 
72
73
  /**
73
74
  * Get access info for this connection.
74
- * It's async as it is fetched from the API.
75
+ *
76
+ * Memoized per-Connection: the first call fetches from the server and
77
+ * caches the result; subsequent calls return the cached copy in O(1).
78
+ * Pass `forceRefresh: true` to invalidate the cache and fetch a fresh
79
+ * copy from the server — used internally by `connection.socket` to
80
+ * react to Plan 66 `accessUpdated` server-push events. A failed
81
+ * server fetch leaves any prior cached value intact.
82
+ *
83
+ * @param {boolean} [forceRefresh=false] - bypass + refresh the cache
75
84
  * @returns {Promise<AccessInfo>} Promise resolving to the access info
76
85
  */
77
- async accessInfo () {
78
- return this.get('access-info', null);
86
+ async accessInfo (forceRefresh = false) {
87
+ if (!forceRefresh && this._accessInfoCache != null) return this._accessInfoCache;
88
+ const fresh = await this.get('access-info', null);
89
+ this._accessInfoCache = fresh;
90
+ return fresh;
79
91
  }
80
92
 
81
93
  /**
@@ -416,6 +428,48 @@ class Connection {
416
428
  return utils.buildAPIEndpoint(this);
417
429
  }
418
430
 
431
+ /**
432
+ * Plan 66 (open-pryv.io ≥ 2.0.0-pre.X): update an access by composite id.
433
+ * Wraps `accesses.update` and translates the 409 `stale-resource` response
434
+ * into a typed `StaleAccessIdError` so callers can `instanceof`-test and
435
+ * refetch + retry without re-parsing the inner error.
436
+ *
437
+ * Pass `id` as the wire-format reference returned by the server — bare
438
+ * cuid on a never-updated access, composite `<base>:<serial>` otherwise.
439
+ * `changes` is the body of mutable fields (name, deviceName, permissions,
440
+ * expireAfter, expires:null, clientData).
441
+ *
442
+ * @param {string} id
443
+ * @param {Object} changes
444
+ * @returns {Promise<Object>} the updated access (with new composite id)
445
+ * @throws {StaleAccessIdError} if the server reports the id is stale
446
+ */
447
+ async updateAccess (id, changes) {
448
+ try {
449
+ return await this.apiOne('accesses.update', { id, update: changes }, 'access');
450
+ } catch (e) {
451
+ if (e && e.innerObject && e.innerObject.id === 'stale-resource') {
452
+ throw new StaleAccessIdError(e.message, e.innerObject.data || {});
453
+ }
454
+ throw e;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Plan 66: fetch an access by composite id including its full version
460
+ * history (oldest first). Server: `accesses.getOne ?includeHistory=true`.
461
+ *
462
+ * Useful for audit views. Pass the composite `<base>:<serial>` to
463
+ * inspect a specific past version (the result's `current` field then
464
+ * points at the live head's composite id).
465
+ *
466
+ * @param {string} id
467
+ * @returns {Promise<{ access: Object, current?: string, history?: Object[] }>}
468
+ */
469
+ async getAccessWithHistory (id) {
470
+ return await this.apiOne('accesses.getOne', { id, includeHistory: true });
471
+ }
472
+
419
473
  // private method that handle meta data parsing
420
474
  _handleMeta (res, requestLocalTimestamp) {
421
475
  if (!res.meta) throw new Error('Cannot find .meta in response.');
package/src/cmc.js ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ /**
7
+ * CMC (Cross-account Messaging & Consent) client-side helpers.
8
+ *
9
+ * Mirrors the server plugin's slug + stream-id helpers so app code can
10
+ * build stream-ids deterministically without depending on the server's
11
+ * private modules. See server-side `components/cmc/src/{slug,constants}.ts`.
12
+ *
13
+ * Stream-id model:
14
+ * :_cmc: reserved root
15
+ * :_cmc:inbox one-shot lifecycle (cross-app)
16
+ * :_cmc:apps:<app-code>:[<path>:]chats:<counterparty-slug>
17
+ * :_cmc:apps:<app-code>:[<path>:]collectors:<counterparty-slug>
18
+ *
19
+ * `counterparty-slug` = `<username>--<host-slug>` where host-slug is the
20
+ * host with `.` replaced by `-`.
21
+ *
22
+ * @memberof pryv
23
+ * @namespace pryv.cmc
24
+ */
25
+
26
+ // --- Constants ---
27
+ // All :_cmc:* identifiers compose from NS. If the namespace is ever
28
+ // rebranded (e.g. to ':_xchg:' or similar), changing NS alone updates
29
+ // every constant + every helper that builds a stream-id.
30
+ const NS = ':_cmc:';
31
+ const NS_INBOX = NS + 'inbox';
32
+ const NS_APPS = NS + 'apps';
33
+ const NS_INTERNAL = NS + '_internal';
34
+ const NS_INTERNAL_RETRIES = NS_INTERNAL + ':retries';
35
+
36
+ const ET_REQUEST = 'consent/request-cmc';
37
+ const ET_ACCEPT = 'consent/accept-cmc';
38
+ const ET_REFUSE = 'consent/refuse-cmc';
39
+ const ET_REVOKE = 'consent/revoke-cmc';
40
+ const ET_CHAT = 'message/chat-cmc';
41
+ const ET_SYSTEM_ALERT = 'notification/alert-cmc';
42
+ const ET_SYSTEM_ACK = 'notification/ack-cmc';
43
+ const ET_SYSTEM_SCOPE_REQUEST = 'consent/scope-request-cmc';
44
+ const ET_SYSTEM_SCOPE_UPDATE = 'consent/scope-update-cmc';
45
+
46
+ const EVENT_TYPES_LIFECYCLE = [ET_REQUEST, ET_ACCEPT, ET_REFUSE, ET_REVOKE];
47
+ const EVENT_TYPES_CHAT = [ET_CHAT];
48
+ const EVENT_TYPES_SYSTEM = [
49
+ ET_SYSTEM_ALERT,
50
+ ET_SYSTEM_ACK,
51
+ ET_SYSTEM_SCOPE_REQUEST,
52
+ ET_SYSTEM_SCOPE_UPDATE
53
+ ];
54
+
55
+ // --- Slug helpers ---
56
+
57
+ const SEPARATOR = '--';
58
+ const SLUG_PIECE_RE = /^[a-z0-9-]+$/;
59
+
60
+ function assertNonEmpty (label, value) {
61
+ if (typeof value !== 'string' || value.length === 0) {
62
+ throw new Error('cmc-slug: ' + label + ' must be a non-empty string');
63
+ }
64
+ return value;
65
+ }
66
+
67
+ function assertSlugPiece (label, value) {
68
+ if (!SLUG_PIECE_RE.test(value)) {
69
+ throw new Error(
70
+ 'cmc-slug: ' + label + ' "' + value + '" must match ' + SLUG_PIECE_RE.toString()
71
+ );
72
+ }
73
+ if (value.includes(SEPARATOR)) {
74
+ throw new Error(
75
+ 'cmc-slug: ' + label + ' "' + value + '" must not contain the double-hyphen separator'
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Slugify a host: lowercase and replace `.` with `-`.
82
+ */
83
+ function slugifyHost (host) {
84
+ assertNonEmpty('host', host);
85
+ // Strip trailing port (`:3000`) — port doesn't affect cross-account
86
+ // identity. Two users on the same hostname are the same platform
87
+ // regardless of which port their api endpoint listens on. Mirrors
88
+ // the server-side helper in open-pryv.io components/cmc/src/slug.ts.
89
+ const hostNoPort = host.replace(/:\d+$/, '');
90
+ return hostNoPort.toLowerCase().replace(/\./g, '-');
91
+ }
92
+
93
+ /**
94
+ * Build a counterparty slug `<username>--<host-slug>`.
95
+ *
96
+ * @param {Object} params
97
+ * @param {string} params.username
98
+ * @param {string} params.host - full hostname (e.g. 'pryv.me')
99
+ * @returns {string}
100
+ */
101
+ function counterpartySlug (params) {
102
+ const username = assertNonEmpty('username', params.username).toLowerCase();
103
+ assertSlugPiece('username', username);
104
+ const hostSlug = slugifyHost(params.host);
105
+ assertSlugPiece('host-slug', hostSlug);
106
+ return username + SEPARATOR + hostSlug;
107
+ }
108
+
109
+ /**
110
+ * Parse a counterparty slug back to its pieces. Note: the host-slug is
111
+ * lossy — `pryv-me` could come from `pryv.me` or `pryv-me`. Store the
112
+ * canonical host alongside the slug if you need it.
113
+ *
114
+ * @param {string} slug
115
+ * @returns {{ username: string, hostSlug: string }}
116
+ */
117
+ function parseCounterpartySlug (slug) {
118
+ assertNonEmpty('slug', slug);
119
+ const pieces = slug.split(SEPARATOR);
120
+ if (pieces.length !== 2) {
121
+ throw new Error(
122
+ 'cmc-slug: counterparty slug "' + slug + '" must have exactly 2 ' +
123
+ 'double-hyphen-separated pieces, got ' + pieces.length
124
+ );
125
+ }
126
+ for (const piece of pieces) {
127
+ assertSlugPiece('slug piece', piece);
128
+ }
129
+ return { username: pieces[0], hostSlug: pieces[1] };
130
+ }
131
+
132
+ // --- Stream-id builders ---
133
+
134
+ /** `<scopeStreamId>:chats` */
135
+ function chatsParentUnder (scopeStreamId) {
136
+ return scopeStreamId + ':chats';
137
+ }
138
+
139
+ /** `<scopeStreamId>:chats:<counterparty-slug>` */
140
+ function chatStreamUnder (scopeStreamId, slug) {
141
+ return scopeStreamId + ':chats:' + slug;
142
+ }
143
+
144
+ /** `<scopeStreamId>:collectors` */
145
+ function collectorsParentUnder (scopeStreamId) {
146
+ return scopeStreamId + ':collectors';
147
+ }
148
+
149
+ /** `<scopeStreamId>:collectors:<counterparty-slug>` */
150
+ function collectorStreamUnder (scopeStreamId, slug) {
151
+ return scopeStreamId + ':collectors:' + slug;
152
+ }
153
+
154
+ /**
155
+ * Build the app-scope root `:_cmc:apps:<app-code>`.
156
+ */
157
+ function appScope (appCode) {
158
+ assertNonEmpty('appCode', appCode);
159
+ return NS_APPS + ':' + appCode;
160
+ }
161
+
162
+ // --- Classification predicates ---
163
+
164
+ /** Does this stream-id live under the :_cmc: namespace? */
165
+ function isCmcStreamId (streamId) {
166
+ return streamId === ':_cmc' || streamId.startsWith(NS);
167
+ }
168
+
169
+ const APP_NESTED_PLUGIN_RE = /:_cmc:apps:[^:]+(?::[^:]+)*:(chats|collectors)(?::|$)/;
170
+
171
+ /** True if this id is at or beneath chats/collectors under :_cmc:apps:*. */
172
+ function isAppNestedPluginStream (streamId) {
173
+ return APP_NESTED_PLUGIN_RE.test(streamId);
174
+ }
175
+
176
+ /**
177
+ * Extract the app-code segment from `:_cmc:apps:<app-code>[:...]`.
178
+ * Returns null for ids that aren't under `:_cmc:apps:`.
179
+ */
180
+ function getAppCode (streamId) {
181
+ if (!streamId.startsWith(NS_APPS + ':')) return null;
182
+ const rest = streamId.substring(NS_APPS.length + 1);
183
+ const colonIdx = rest.indexOf(':');
184
+ return colonIdx === -1 ? rest : rest.substring(0, colonIdx);
185
+ }
186
+
187
+ const CHAT_STREAM_ID_RE = /^(:_cmc:apps:[^:]+(?::[^:]+)*):chats:([a-z0-9-]+--[a-z0-9-]+)$/;
188
+ const COLLECTOR_STREAM_ID_RE = /^(:_cmc:apps:[^:]+(?::[^:]+)*):collectors:([a-z0-9-]+--[a-z0-9-]+)$/;
189
+
190
+ /**
191
+ * Parse a chat trigger stream-id into its components.
192
+ * Returns null on shape mismatch.
193
+ */
194
+ function parseChatStreamId (streamId) {
195
+ if (typeof streamId !== 'string') return null;
196
+ const m = streamId.match(CHAT_STREAM_ID_RE);
197
+ if (m == null) return null;
198
+ let counterparty;
199
+ try {
200
+ counterparty = parseCounterpartySlug(m[2]);
201
+ } catch (_e) {
202
+ return null;
203
+ }
204
+ return {
205
+ appCode: getAppCode(m[1]),
206
+ scopeStreamId: m[1],
207
+ counterpartySlug: m[2],
208
+ counterparty
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Parse a collectors trigger stream-id into its components.
214
+ * Returns null on shape mismatch.
215
+ */
216
+ function parseCollectorStreamId (streamId) {
217
+ if (typeof streamId !== 'string') return null;
218
+ const m = streamId.match(COLLECTOR_STREAM_ID_RE);
219
+ if (m == null) return null;
220
+ let counterparty;
221
+ try {
222
+ counterparty = parseCounterpartySlug(m[2]);
223
+ } catch (_e) {
224
+ return null;
225
+ }
226
+ return {
227
+ appCode: getAppCode(m[1]),
228
+ scopeStreamId: m[1],
229
+ counterpartySlug: m[2],
230
+ counterparty
231
+ };
232
+ }
233
+
234
+ // --- Actor helpers (apiEndpoint → { token, username, host }) ---
235
+
236
+ /**
237
+ * Extract `{ token, username, host }` from a Pryv apiEndpoint, given
238
+ * the platform's `service.info.api` URL template.
239
+ *
240
+ * Pryv apiEndpoints follow one of two URL shapes (the difference is
241
+ * platform-defined, encoded in `service.info.api`):
242
+ *
243
+ * subdomain template `https://{username}.<domain>/`
244
+ * endpoint `https://<token>@<username>.<domain>/`
245
+ * path-style template `https://<host>/{username}/`
246
+ * endpoint `https://<token>@<host>/<username>/`
247
+ *
248
+ * This helper inverts whichever template the platform serves, returning
249
+ * the **canonical host** (no `<username>.` subdomain prefix in subdomain
250
+ * mode) — that's the host CMC uses for cross-account identity (slugs,
251
+ * counterparty matching).
252
+ *
253
+ * @param {string} apiEndpoint e.g. 'https://t0k3n@alice.pryv.me/'
254
+ * @param {string} serviceInfoApi e.g. 'https://{username}.pryv.me/'
255
+ * from /service/info → field `api`.
256
+ * @returns {{ token: string|null, username: string|null, host: string }}
257
+ *
258
+ * @example
259
+ * const conn = new pryv.Connection(apiEndpoint);
260
+ * const info = await conn.service.info();
261
+ * const me = pryv.cmc.extractActor(apiEndpoint, info.api);
262
+ * // → { token: 't0k3n', username: 'alice', host: 'pryv.me' }
263
+ */
264
+ function extractActor (apiEndpoint, serviceInfoApi) {
265
+ // Avoid circular require: utils is the consumer, cmc the provider.
266
+ // We pull at call-time so cmc.js stays loadable without requiring
267
+ // utils early.
268
+ const utils = require('./utils');
269
+ const { token, endpoint } = utils.extractTokenAndAPIEndpoint(apiEndpoint);
270
+ // Match the api template's variable position to find the username.
271
+ // Both endpoint + template are guaranteed to have a trailing slash.
272
+ const tplIdx = serviceInfoApi.indexOf('{username}');
273
+ if (tplIdx < 0) {
274
+ // Template doesn't carry {username} — operator-defined, can't decompose.
275
+ let host = '';
276
+ try { host = new URL(endpoint).host; } catch (_e) {}
277
+ return { token, username: null, host };
278
+ }
279
+ const tplPrefix = serviceInfoApi.slice(0, tplIdx);
280
+ const tplSuffix = serviceInfoApi.slice(tplIdx + '{username}'.length);
281
+ if (!endpoint.startsWith(tplPrefix) || !endpoint.endsWith(tplSuffix)) {
282
+ let host = '';
283
+ try { host = new URL(endpoint).host; } catch (_e) {}
284
+ return { token, username: null, host };
285
+ }
286
+ const username = endpoint.slice(tplPrefix.length, endpoint.length - tplSuffix.length);
287
+ // For subdomain templates, host is `<username>.<domain>` — but the
288
+ // canonical CMC host is the bare `<domain>` (the platform identity,
289
+ // not per-user). For path-style, host is just the literal host.
290
+ // Disambiguate by where {username} sits in the template:
291
+ // - subdomain → prefix ends with `://` (username right after scheme)
292
+ // - path-style → prefix has more after `://` (host already in prefix)
293
+ const isSubdomainTemplate = /:\/\/$/.test(tplPrefix);
294
+ let host;
295
+ if (isSubdomainTemplate) {
296
+ // tplSuffix starts with the domain (e.g. '.pryv.me/')
297
+ host = tplSuffix.replace(/^\.+/, '').replace(/\/+$/, '');
298
+ } else {
299
+ // path-style — host is in the prefix's URL
300
+ try {
301
+ host = new URL(tplPrefix).host;
302
+ } catch (_e) {
303
+ host = '';
304
+ }
305
+ }
306
+ return { token, username, host };
307
+ }
308
+
309
+ module.exports = {
310
+ // namespace constants
311
+ NS,
312
+ NS_INBOX,
313
+ NS_APPS,
314
+ NS_INTERNAL,
315
+ NS_INTERNAL_RETRIES,
316
+ // actor helper
317
+ extractActor,
318
+ // event types
319
+ ET_REQUEST,
320
+ ET_ACCEPT,
321
+ ET_REFUSE,
322
+ ET_REVOKE,
323
+ ET_CHAT,
324
+ ET_SYSTEM_ALERT,
325
+ ET_SYSTEM_ACK,
326
+ ET_SYSTEM_SCOPE_REQUEST,
327
+ ET_SYSTEM_SCOPE_UPDATE,
328
+ EVENT_TYPES_LIFECYCLE,
329
+ EVENT_TYPES_CHAT,
330
+ EVENT_TYPES_SYSTEM,
331
+ // slug helpers
332
+ SEPARATOR,
333
+ slugifyHost,
334
+ counterpartySlug,
335
+ parseCounterpartySlug,
336
+ // stream-id builders
337
+ appScope,
338
+ chatsParentUnder,
339
+ chatStreamUnder,
340
+ collectorsParentUnder,
341
+ collectorStreamUnder,
342
+ // classification + parsing
343
+ isCmcStreamId,
344
+ isAppNestedPluginStream,
345
+ getAppCode,
346
+ parseChatStreamId,
347
+ parseCollectorStreamId
348
+ };
package/src/index.d.ts CHANGED
@@ -723,7 +723,12 @@ declare module 'pryv' {
723
723
  fields: string[],
724
724
  points: Array<Array<number | string>>,
725
725
  ): Promise<HFSeriesAddResult>;
726
- accessInfo(): Promise<AccessInfo>;
726
+ /** Memoized; pass `forceRefresh: true` to bypass + refresh the cache. */
727
+ accessInfo(forceRefresh?: boolean): Promise<AccessInfo>;
728
+ /** Plan 66: update an access by composite id. */
729
+ updateAccess(id: string, changes: object): Promise<object>;
730
+ /** Plan 66: fetch an access including its full version history. */
731
+ getAccessWithHistory(id: string): Promise<{ access: object, current?: string, history?: object[] }>;
727
732
  revoke(throwOnFail?: boolean, usingConnection?: Connection): Promise<any>;
728
733
  readonly deltaTime: number;
729
734
  readonly apiEndpoint: string;
package/src/index.js CHANGED
@@ -10,7 +10,10 @@
10
10
  * @property {pryv.Browser} Browser - Browser Tools - Access request helpers and visuals (button)
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
+ * @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 - Plan 66: thrown when a Pryv.io server rejects an `accesses.update` / `accesses.delete` with a 409 stale-resource. Refetch + retry.
13
15
  * @property {Object} ERRORS - Catalogue of Pryv API error ids (mirrors open-pryv.io/components/errors)
16
+ * @property {pryv.cmc} cmc - Cross-account Messaging & Consent helpers (slug + stream-id builders + parsers)
14
17
  */
15
18
  module.exports = {
16
19
  Service: require('./Service'),
@@ -20,6 +23,8 @@ module.exports = {
20
23
  utils: require('./utils'),
21
24
  PryvError: require('./lib/PryvError'),
22
25
  MfaRequiredError: require('./lib/MfaRequiredError'),
26
+ StaleAccessIdError: require('./lib/StaleAccessIdError'),
23
27
  ERRORS: require('./lib/errorIds'),
28
+ cmc: require('./cmc'),
24
29
  version: require('../package.json').version
25
30
  };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ const PryvError = require('./PryvError');
7
+
8
+ /**
9
+ * Plan 66: typed error surfaced when a Pryv.io server (≥ 2.0.0-pre.X)
10
+ * rejects an `accesses.update` or `accesses.delete` call with a
11
+ * 409 `stale-resource` response.
12
+ *
13
+ * The composite access id `<base>:<serial>` carries the version the
14
+ * caller last observed. If the access has since been updated, the
15
+ * server rejects the call so the caller refetches the current head
16
+ * (`connection.api('accesses.getOne', { id: base })`) and retries
17
+ * with the fresh composite id.
18
+ *
19
+ * Reach for `.data.provided` to see what the caller sent and
20
+ * `.data.currentSerial` to see what the server currently has.
21
+ *
22
+ * @extends PryvError
23
+ */
24
+ class StaleAccessIdError extends PryvError {
25
+ /**
26
+ * @param {string} message
27
+ * @param {{ provided?: string, currentSerial?: number | null }} data
28
+ */
29
+ constructor (message, data) {
30
+ super(message);
31
+ this.name = 'StaleAccessIdError';
32
+ /** @type {{ provided?: string, currentSerial?: number | null }} */
33
+ this.data = data || {};
34
+ if (Error.captureStackTrace) {
35
+ Error.captureStackTrace(this, StaleAccessIdError);
36
+ }
37
+ }
38
+ }
39
+
40
+ module.exports = StaleAccessIdError;
package/src/utils.js CHANGED
@@ -155,6 +155,56 @@ const utils = module.exports = {
155
155
  return url.replace(PRYV_REGEXP, '');
156
156
  },
157
157
 
158
+ /**
159
+ * Plan 66 (open-pryv.io ≥ 2.0.0-pre.X): parse a wire-format access
160
+ * reference into `{ base, serial }`. Accepts both bare cuid
161
+ * (`"abc123"` → `{ base: 'abc123', serial: null }`) and composite
162
+ * (`"abc123:3"` → `{ base: 'abc123', serial: 3 }`). Throws on
163
+ * malformed input. Apply this to `access.id`, `access.createdBy`,
164
+ * `access.modifiedBy`, and `streamIds` entries of the form
165
+ * `access-<base>:<serial>` from audit events.
166
+ * @memberof pryv.utils
167
+ * @param {string} ref - Access reference string
168
+ * @returns {{ base: string, serial: number | null }}
169
+ */
170
+ parseAccessRef: function (ref) {
171
+ if (typeof ref !== 'string' || ref.length === 0) {
172
+ throw new Error('parseAccessRef: expected a non-empty string, got ' + JSON.stringify(ref));
173
+ }
174
+ const colonIdx = ref.indexOf(':');
175
+ if (colonIdx === -1) return { base: ref, serial: null };
176
+ const base = ref.slice(0, colonIdx);
177
+ const tail = ref.slice(colonIdx + 1);
178
+ if (base.length === 0) {
179
+ throw new Error('parseAccessRef: empty base in ' + JSON.stringify(ref));
180
+ }
181
+ const serial = Number(tail);
182
+ if (!Number.isInteger(serial) || serial < 1) {
183
+ throw new Error('parseAccessRef: serial must be a positive integer, got ' + JSON.stringify(tail));
184
+ }
185
+ return { base, serial };
186
+ },
187
+
188
+ /**
189
+ * Plan 66: render an `{ base, serial }` pair back to the wire
190
+ * format. Bare cuid when serial is null/undefined; `<base>:<serial>`
191
+ * otherwise. Mostly used to construct the composite id when calling
192
+ * `connection.api()` for `accesses.update` / `accesses.delete`.
193
+ * @memberof pryv.utils
194
+ * @param {{ base: string, serial?: number | null }} ref
195
+ * @returns {string}
196
+ */
197
+ serializeAccessRef: function (ref) {
198
+ if (ref == null || typeof ref.base !== 'string' || ref.base.length === 0) {
199
+ throw new Error('serializeAccessRef: ref.base must be a non-empty string');
200
+ }
201
+ if (ref.serial == null) return ref.base;
202
+ if (!Number.isInteger(ref.serial) || ref.serial < 1) {
203
+ throw new Error('serializeAccessRef: serial must be a positive integer, got ' + JSON.stringify(ref.serial));
204
+ }
205
+ return ref.base + ':' + ref.serial;
206
+ },
207
+
158
208
  /**
159
209
  * Extract query parameters from a URL
160
210
  * @memberof pryv.utils
@@ -499,5 +499,31 @@ describe('[CONX] Connection', () => {
499
499
  expect(accessInfoUser.token).to.exist;
500
500
  expect(newUser.access.token).to.equal(accessInfoUser.token);
501
501
  });
502
+
503
+ // Plan 66 — accessInfo caching + forceRefresh.
504
+
505
+ it('[CAIC] accessInfo() memoizes — second call returns the same object reference', async () => {
506
+ const regexAPIandToken = /(.+):\/\/(.+)/gm;
507
+ const res = regexAPIandToken.exec(testData.apiEndpoint);
508
+ const apiEndpointWithToken = res[1] + '://' + newUser.access.token + '@' + res[2];
509
+ const cachingConn = new pryv.Connection(apiEndpointWithToken);
510
+ const first = await cachingConn.accessInfo();
511
+ const second = await cachingConn.accessInfo();
512
+ expect(second).to.equal(first); // same reference — served from cache
513
+ });
514
+
515
+ it('[CAID] accessInfo(true) forces a refresh and replaces the cached object', async () => {
516
+ const regexAPIandToken = /(.+):\/\/(.+)/gm;
517
+ const res = regexAPIandToken.exec(testData.apiEndpoint);
518
+ const apiEndpointWithToken = res[1] + '://' + newUser.access.token + '@' + res[2];
519
+ const cachingConn = new pryv.Connection(apiEndpointWithToken);
520
+ const first = await cachingConn.accessInfo();
521
+ const refreshed = await cachingConn.accessInfo(true);
522
+ expect(refreshed).to.not.equal(first); // distinct object — re-fetched
523
+ expect(refreshed.token).to.equal(first.token); // same content
524
+ // Next non-forced call returns the refreshed copy from cache.
525
+ const cached = await cachingConn.accessInfo();
526
+ expect(cached).to.equal(refreshed);
527
+ });
502
528
  });
503
529
  });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, expect */
6
+
7
+ const cmc = require('../src/cmc');
8
+
9
+ describe('[CMCX] pryv.cmc client helpers', function () {
10
+ describe('[CMCXC] constants', function () {
11
+ it('[CMCXCA] exposes namespace + event-type constants', function () {
12
+ expect(cmc.NS).to.equal(':_cmc:');
13
+ expect(cmc.NS_INBOX).to.equal(':_cmc:inbox');
14
+ expect(cmc.NS_APPS).to.equal(':_cmc:apps');
15
+ expect(cmc.NS_INTERNAL).to.equal(':_cmc:_internal');
16
+ expect(cmc.NS_INTERNAL_RETRIES).to.equal(':_cmc:_internal:retries');
17
+ expect(cmc.ET_REQUEST).to.equal('consent/request-cmc');
18
+ expect(cmc.ET_ACCEPT).to.equal('consent/accept-cmc');
19
+ expect(cmc.ET_CHAT).to.equal('message/chat-cmc');
20
+ expect(cmc.ET_SYSTEM_ALERT).to.equal('notification/alert-cmc');
21
+ expect(cmc.EVENT_TYPES_LIFECYCLE).to.include('consent/request-cmc');
22
+ expect(cmc.EVENT_TYPES_SYSTEM).to.include('consent/scope-update-cmc');
23
+ });
24
+ });
25
+
26
+ describe('[CMCXS] slug helpers', function () {
27
+ it('[CMCXSA] slugifyHost lowercases + replaces dots with hyphens', function () {
28
+ expect(cmc.slugifyHost('PRYV.me')).to.equal('pryv-me');
29
+ expect(cmc.slugifyHost('a.b.c.example.org')).to.equal('a-b-c-example-org');
30
+ });
31
+
32
+ it('[CMCXSB] counterpartySlug joins username + host-slug with --', function () {
33
+ expect(cmc.counterpartySlug({ username: 'alice', host: 'pryv.me' }))
34
+ .to.equal('alice--pryv-me');
35
+ expect(cmc.counterpartySlug({ username: 'provider-a', host: 'example.com' }))
36
+ .to.equal('provider-a--example-com');
37
+ });
38
+
39
+ it('[CMCXSC] counterpartySlug rejects invalid pieces', function () {
40
+ expect(() => cmc.counterpartySlug({ username: 'has space', host: 'a.b' })).to.throw();
41
+ expect(() => cmc.counterpartySlug({ username: '', host: 'a.b' })).to.throw();
42
+ expect(() => cmc.counterpartySlug({ username: 'a--b', host: 'a.b' })).to.throw();
43
+ });
44
+
45
+ it('[CMCXSD] parseCounterpartySlug round-trips the slug', function () {
46
+ const s = cmc.counterpartySlug({ username: 'alice', host: 'pryv.me' });
47
+ const parsed = cmc.parseCounterpartySlug(s);
48
+ expect(parsed).to.deep.equal({ username: 'alice', hostSlug: 'pryv-me' });
49
+ });
50
+
51
+ it('[CMCXSE] parseCounterpartySlug rejects malformed input', function () {
52
+ expect(() => cmc.parseCounterpartySlug('no-separator')).to.throw();
53
+ expect(() => cmc.parseCounterpartySlug('a--b--c')).to.throw();
54
+ expect(() => cmc.parseCounterpartySlug('')).to.throw();
55
+ });
56
+ });
57
+
58
+ describe('[CMCXB] stream-id builders', function () {
59
+ it('[CMCXBA] appScope produces :_cmc:apps:<app-code>', function () {
60
+ expect(cmc.appScope('my-app')).to.equal(':_cmc:apps:my-app');
61
+ });
62
+
63
+ it('[CMCXBB] chats helpers build the nested path', function () {
64
+ const scope = cmc.appScope('my-app');
65
+ expect(cmc.chatsParentUnder(scope)).to.equal(':_cmc:apps:my-app:chats');
66
+ const slug = cmc.counterpartySlug({ username: 'alice', host: 'pryv.me' });
67
+ expect(cmc.chatStreamUnder(scope, slug)).to.equal(':_cmc:apps:my-app:chats:alice--pryv-me');
68
+ });
69
+
70
+ it('[CMCXBC] collectors helpers build the nested path', function () {
71
+ const scope = ':_cmc:apps:my-app:campaign-2026';
72
+ expect(cmc.collectorsParentUnder(scope)).to.equal(':_cmc:apps:my-app:campaign-2026:collectors');
73
+ expect(cmc.collectorStreamUnder(scope, 'alice--pryv-me'))
74
+ .to.equal(':_cmc:apps:my-app:campaign-2026:collectors:alice--pryv-me');
75
+ });
76
+ });
77
+
78
+ describe('[CMCXP] predicates + parsers', function () {
79
+ it('[CMCXPA] isCmcStreamId true for :_cmc:* + false for non-cmc', function () {
80
+ expect(cmc.isCmcStreamId(':_cmc:apps:foo')).to.equal(true);
81
+ expect(cmc.isCmcStreamId(':_cmc:inbox')).to.equal(true);
82
+ expect(cmc.isCmcStreamId('fertility')).to.equal(false);
83
+ expect(cmc.isCmcStreamId(':_system:account:email')).to.equal(false);
84
+ });
85
+
86
+ it('[CMCXPB] isAppNestedPluginStream true for chats/collectors leaves', function () {
87
+ expect(cmc.isAppNestedPluginStream(':_cmc:apps:my-app:chats:alice--pryv-me')).to.equal(true);
88
+ expect(cmc.isAppNestedPluginStream(':_cmc:apps:my-app:collectors:alice--pryv-me')).to.equal(true);
89
+ expect(cmc.isAppNestedPluginStream(':_cmc:apps:my-app:study-A:chats')).to.equal(true);
90
+ expect(cmc.isAppNestedPluginStream(':_cmc:apps:my-app:study-A')).to.equal(false);
91
+ expect(cmc.isAppNestedPluginStream(':_cmc:apps:my-app:chats-style-data')).to.equal(false);
92
+ });
93
+
94
+ it('[CMCXPC] getAppCode pulls the app segment', function () {
95
+ expect(cmc.getAppCode(':_cmc:apps:my-app')).to.equal('my-app');
96
+ expect(cmc.getAppCode(':_cmc:apps:my-app:campaign-2026')).to.equal('my-app');
97
+ expect(cmc.getAppCode(':_cmc:inbox')).to.equal(null);
98
+ expect(cmc.getAppCode('fertility')).to.equal(null);
99
+ });
100
+
101
+ it('[CMCXPD] parseChatStreamId extracts scope + counterparty', function () {
102
+ const r = cmc.parseChatStreamId(':_cmc:apps:my-app:campaign-2026:chats:alice--pryv-me');
103
+ expect(r.appCode).to.equal('my-app');
104
+ expect(r.scopeStreamId).to.equal(':_cmc:apps:my-app:campaign-2026');
105
+ expect(r.counterpartySlug).to.equal('alice--pryv-me');
106
+ expect(r.counterparty).to.deep.equal({ username: 'alice', hostSlug: 'pryv-me' });
107
+ });
108
+
109
+ it('[CMCXPE] parseChatStreamId returns null for non-chat ids', function () {
110
+ expect(cmc.parseChatStreamId(':_cmc:inbox')).to.equal(null);
111
+ expect(cmc.parseChatStreamId(':_cmc:apps:my-app:collectors:foo--bar')).to.equal(null);
112
+ expect(cmc.parseChatStreamId('arbitrary-stream')).to.equal(null);
113
+ });
114
+
115
+ it('[CMCXPF] parseCollectorStreamId extracts scope + counterparty', function () {
116
+ const r = cmc.parseCollectorStreamId(':_cmc:apps:my-app:collectors:alice--pryv-me');
117
+ expect(r.appCode).to.equal('my-app');
118
+ expect(r.scopeStreamId).to.equal(':_cmc:apps:my-app');
119
+ expect(r.counterpartySlug).to.equal('alice--pryv-me');
120
+ });
121
+
122
+ it('[CMCXPG] parseCollectorStreamId returns null for non-collector ids', function () {
123
+ expect(cmc.parseCollectorStreamId(':_cmc:apps:my-app:chats:foo--bar')).to.equal(null);
124
+ expect(cmc.parseCollectorStreamId(':_cmc:apps:my-app:collectors:no-separator')).to.equal(null);
125
+ });
126
+ });
127
+
128
+ describe('[CMCXI] integration with pryv module', function () {
129
+ it('[CMCXIA] exposed as pryv.cmc', function () {
130
+ const pryv = require('../src');
131
+ expect(pryv.cmc).to.exist;
132
+ expect(pryv.cmc.counterpartySlug).to.be.a('function');
133
+ expect(pryv.cmc.NS).to.equal(':_cmc:');
134
+ });
135
+ });
136
+
137
+ describe('[CMCXA] extractActor', function () {
138
+ it('[CMCXAA] subdomain template — host strips the {username}. prefix', function () {
139
+ const r = cmc.extractActor(
140
+ 'https://t0k3n@alice.pryv.me/',
141
+ 'https://{username}.pryv.me/'
142
+ );
143
+ expect(r).to.deep.equal({ token: 't0k3n', username: 'alice', host: 'pryv.me' });
144
+ });
145
+
146
+ it('[CMCXAB] path-style template — host is the literal hostname[:port]', function () {
147
+ const r = cmc.extractActor(
148
+ 'http://t0k3n@127.0.0.1:3000/alice/',
149
+ 'http://127.0.0.1:3000/{username}/'
150
+ );
151
+ expect(r).to.deep.equal({ token: 't0k3n', username: 'alice', host: '127.0.0.1:3000' });
152
+ });
153
+
154
+ it('[CMCXAC] returns username:null when the endpoint doesn\'t match the template', function () {
155
+ const r = cmc.extractActor(
156
+ 'https://t0k3n@somewhere.else.com/',
157
+ 'https://{username}.pryv.me/'
158
+ );
159
+ expect(r.username).to.equal(null);
160
+ });
161
+
162
+ it('[CMCXAD] returns token:null when endpoint carries no token', function () {
163
+ const r = cmc.extractActor(
164
+ 'https://alice.pryv.me/',
165
+ 'https://{username}.pryv.me/'
166
+ );
167
+ expect(r.token).to.equal(null);
168
+ expect(r.username).to.equal('alice');
169
+ expect(r.host).to.equal('pryv.me');
170
+ });
171
+ });
172
+ });
@@ -59,4 +59,48 @@ describe('[UTLX] utils', function () {
59
59
  expect(apiEndpoint).to.equal(testData.apiEndpoint);
60
60
  done();
61
61
  });
62
+
63
+ // Plan 66 — composite access references.
64
+
65
+ it('[UTLF] parseAccessRef on a bare cuid returns { base, serial: null }', function () {
66
+ const ref = pryv.utils.parseAccessRef('abc123def456');
67
+ expect(ref).to.eql({ base: 'abc123def456', serial: null });
68
+ });
69
+
70
+ it('[UTLG] parseAccessRef on a composite returns { base, serial }', function () {
71
+ const ref = pryv.utils.parseAccessRef('abc123:7');
72
+ expect(ref).to.eql({ base: 'abc123', serial: 7 });
73
+ });
74
+
75
+ it('[UTLH] parseAccessRef on garbage throws', function () {
76
+ expect(() => pryv.utils.parseAccessRef('')).to.throw();
77
+ expect(() => pryv.utils.parseAccessRef(':1')).to.throw();
78
+ expect(() => pryv.utils.parseAccessRef('abc:notanumber')).to.throw();
79
+ expect(() => pryv.utils.parseAccessRef('abc:0')).to.throw();
80
+ expect(() => pryv.utils.parseAccessRef('abc:-1')).to.throw();
81
+ expect(() => pryv.utils.parseAccessRef(null)).to.throw();
82
+ });
83
+
84
+ it('[UTLI] serializeAccessRef round-trips parseAccessRef', function () {
85
+ const samples = ['plainCuid', 'plainCuid:1', 'plainCuid:42'];
86
+ for (const s of samples) {
87
+ expect(pryv.utils.serializeAccessRef(pryv.utils.parseAccessRef(s))).to.equal(s);
88
+ }
89
+ });
90
+
91
+ it('[UTLJ] serializeAccessRef rejects bad inputs', function () {
92
+ expect(() => pryv.utils.serializeAccessRef(null)).to.throw();
93
+ expect(() => pryv.utils.serializeAccessRef({ base: '' })).to.throw();
94
+ expect(() => pryv.utils.serializeAccessRef({ base: 'abc', serial: 0 })).to.throw();
95
+ expect(() => pryv.utils.serializeAccessRef({ base: 'abc', serial: 1.5 })).to.throw();
96
+ });
97
+
98
+ it('[UTLK] StaleAccessIdError extends PryvError', function () {
99
+ const err = new pryv.StaleAccessIdError('stale!', { provided: 'abc:1', currentSerial: 2 });
100
+ expect(err).to.be.instanceOf(pryv.StaleAccessIdError);
101
+ expect(err).to.be.instanceOf(pryv.PryvError);
102
+ expect(err.data.provided).to.equal('abc:1');
103
+ expect(err.data.currentSerial).to.equal(2);
104
+ expect(err.name).to.equal('StaleAccessIdError');
105
+ });
62
106
  });