pryv 3.2.0 → 3.3.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 +2 -0
- package/package.json +1 -1
- package/src/index.d.ts +15 -0
- package/src/index.js +0 -2
- package/src/utils.js +82 -0
- package/test/utils.test.js +37 -0
- package/src/cmc.js +0 -348
- package/test/cmc.test.js +0 -172
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ Other distributions available:
|
|
|
57
57
|
|
|
58
58
|
- Socket.IO: [NPM package](https://www.npmjs.com/package/@pryv/socket.io), [README](https://github.com/pryv/lib-js/tree/master/components/pryv-socket.io#readme)
|
|
59
59
|
- Monitor: [NPM package](https://www.npmjs.com/package/@pryv/monitor), [README](https://github.com/pryv/lib-js/tree/master/components/pryv-monitor#readme)
|
|
60
|
+
- CMC (Cross-account Messaging & Consent) client helpers: [NPM package](https://www.npmjs.com/package/@pryv/cmc), [README](https://github.com/pryv/lib-js/tree/master/components/pryv-cmc#readme)
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
### Quick example
|
|
@@ -677,6 +678,7 @@ The project is structured as a monorepo with components (a.k.a. workspaces in NP
|
|
|
677
678
|
- `pryv`: the library
|
|
678
679
|
- `pryv-socket.io`: Socket.IO add-on
|
|
679
680
|
- `pryv-monitor`: Monitor add-on
|
|
681
|
+
- `pryv-cmc`: CMC (Cross-account Messaging & Consent) client helpers
|
|
680
682
|
|
|
681
683
|
|
|
682
684
|
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -1069,10 +1069,24 @@ declare module 'pryv' {
|
|
|
1069
1069
|
token: string | null;
|
|
1070
1070
|
};
|
|
1071
1071
|
|
|
1072
|
+
/**
|
|
1073
|
+
* Decomposed form of an APIEndpoint, returned by
|
|
1074
|
+
* `pryv.utils.decomposeAPIEndpoint`. `host` is the canonical platform
|
|
1075
|
+
* host (no `<username>.` subdomain prefix in subdomain-style
|
|
1076
|
+
* deployments) — the same identity cross-account features (CMC
|
|
1077
|
+
* counterparty slugs, etc.) key on.
|
|
1078
|
+
*/
|
|
1079
|
+
export type DecomposedAPIEndpoint = {
|
|
1080
|
+
token: string | null;
|
|
1081
|
+
username: string | null;
|
|
1082
|
+
host: string;
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1072
1085
|
export const utils: {
|
|
1073
1086
|
isBrowser(): boolean;
|
|
1074
1087
|
extractTokenAndAPIEndpoint(apiEndpoint: string): TokenAndAPIEndpoint;
|
|
1075
1088
|
buildAPIEndpoint(tokenAndAPI: TokenAndAPIEndpoint): string;
|
|
1089
|
+
decomposeAPIEndpoint(apiEndpoint: string, serviceInfoApi: string): DecomposedAPIEndpoint;
|
|
1076
1090
|
browserIsMobileOrTablet(navigator?: string | Navigator): boolean;
|
|
1077
1091
|
cleanURLFromPrYvParams(url: string): string;
|
|
1078
1092
|
getQueryParamsFromURL(url: string): KeyValue;
|
|
@@ -1107,6 +1121,7 @@ declare module 'pryv' {
|
|
|
1107
1121
|
isBrowser(): boolean;
|
|
1108
1122
|
extractTokenAndAPIEndpoint(apiEndpoint: string): TokenAndAPIEndpoint;
|
|
1109
1123
|
buildAPIEndpoint(tokenAndAPI: TokenAndAPIEndpoint): string;
|
|
1124
|
+
decomposeAPIEndpoint(apiEndpoint: string, serviceInfoApi: string): DecomposedAPIEndpoint;
|
|
1110
1125
|
browserIsMobileOrTablet(navigator?: string | Navigator): boolean;
|
|
1111
1126
|
cleanURLFromPrYvParams(url: string): string;
|
|
1112
1127
|
getQueryParamsFromURL(url: string): KeyValue;
|
package/src/index.js
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
* @property {pryv.MfaRequiredError} MfaRequiredError - Thrown by Service.login when the platform returns an mfaToken instead of a token. Carries `.mfaToken`.
|
|
14
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.
|
|
15
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)
|
|
17
16
|
*/
|
|
18
17
|
module.exports = {
|
|
19
18
|
Service: require('./Service'),
|
|
@@ -25,6 +24,5 @@ module.exports = {
|
|
|
25
24
|
MfaRequiredError: require('./lib/MfaRequiredError'),
|
|
26
25
|
StaleAccessIdError: require('./lib/StaleAccessIdError'),
|
|
27
26
|
ERRORS: require('./lib/errorIds'),
|
|
28
|
-
cmc: require('./cmc'),
|
|
29
27
|
version: require('../package.json').version
|
|
30
28
|
};
|
package/src/utils.js
CHANGED
|
@@ -127,6 +127,76 @@ const utils = module.exports = {
|
|
|
127
127
|
return res[1] + '://' + tokenAndAPI.token + '@' + res[2];
|
|
128
128
|
},
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Decompose a Pryv apiEndpoint into `{ token, username, host }` using
|
|
132
|
+
* the platform's `service.info.api` URL template to invert whichever
|
|
133
|
+
* username placement the platform uses (subdomain vs path-style).
|
|
134
|
+
*
|
|
135
|
+
* Pryv apiEndpoints follow one of two URL shapes (the difference is
|
|
136
|
+
* platform-defined, encoded in `service.info.api`):
|
|
137
|
+
*
|
|
138
|
+
* subdomain template `https://{username}.<domain>/`
|
|
139
|
+
* endpoint `https://<token>@<username>.<domain>/`
|
|
140
|
+
* path-style template `https://<host>/{username}/`
|
|
141
|
+
* endpoint `https://<token>@<host>/<username>/`
|
|
142
|
+
*
|
|
143
|
+
* Returns the **canonical platform host** (no `<username>.` subdomain
|
|
144
|
+
* prefix in subdomain mode) — the identity cross-account features
|
|
145
|
+
* (e.g. CMC counterparty slugs) key on, regardless of which user the
|
|
146
|
+
* endpoint belongs to.
|
|
147
|
+
*
|
|
148
|
+
* `token` and `username` are null when the endpoint carries no token /
|
|
149
|
+
* when the endpoint shape doesn't match the template; `host` is
|
|
150
|
+
* best-effort in that case.
|
|
151
|
+
*
|
|
152
|
+
* @memberof pryv.utils
|
|
153
|
+
* @param {APIEndpoint} apiEndpoint e.g. 'https://t0k3n@alice.pryv.me/'
|
|
154
|
+
* @param {string} serviceInfoApi e.g. 'https://{username}.pryv.me/'
|
|
155
|
+
* from /service/info → field `api`.
|
|
156
|
+
* @returns {DecomposedAPIEndpoint}
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* const conn = new pryv.Connection(apiEndpoint);
|
|
160
|
+
* const info = await conn.service.info();
|
|
161
|
+
* const me = pryv.utils.decomposeAPIEndpoint(apiEndpoint, info.api);
|
|
162
|
+
* // → { token: 't0k3n', username: 'alice', host: 'pryv.me' }
|
|
163
|
+
*/
|
|
164
|
+
decomposeAPIEndpoint: function (apiEndpoint, serviceInfoApi) {
|
|
165
|
+
const { token, endpoint } = utils.extractTokenAndAPIEndpoint(apiEndpoint);
|
|
166
|
+
const tplIdx = serviceInfoApi.indexOf('{username}');
|
|
167
|
+
if (tplIdx < 0) {
|
|
168
|
+
// Template doesn't carry {username} — operator-defined, can't decompose.
|
|
169
|
+
let host = '';
|
|
170
|
+
try { host = new URL(endpoint).host; } catch (_e) {}
|
|
171
|
+
return { token, username: null, host };
|
|
172
|
+
}
|
|
173
|
+
const tplPrefix = serviceInfoApi.slice(0, tplIdx);
|
|
174
|
+
const tplSuffix = serviceInfoApi.slice(tplIdx + '{username}'.length);
|
|
175
|
+
if (!endpoint.startsWith(tplPrefix) || !endpoint.endsWith(tplSuffix)) {
|
|
176
|
+
let host = '';
|
|
177
|
+
try { host = new URL(endpoint).host; } catch (_e) {}
|
|
178
|
+
return { token, username: null, host };
|
|
179
|
+
}
|
|
180
|
+
const username = endpoint.slice(tplPrefix.length, endpoint.length - tplSuffix.length);
|
|
181
|
+
// Disambiguate by where {username} sits in the template:
|
|
182
|
+
// - subdomain → prefix ends with `://` (username right after scheme)
|
|
183
|
+
// - path-style → prefix has more after `://` (host already in prefix)
|
|
184
|
+
const isSubdomainTemplate = /:\/\/$/.test(tplPrefix);
|
|
185
|
+
let host;
|
|
186
|
+
if (isSubdomainTemplate) {
|
|
187
|
+
// tplSuffix starts with the domain (e.g. '.pryv.me/')
|
|
188
|
+
host = tplSuffix.replace(/^\.+/, '').replace(/\/+$/, '');
|
|
189
|
+
} else {
|
|
190
|
+
// path-style — host is in the prefix's URL
|
|
191
|
+
try {
|
|
192
|
+
host = new URL(tplPrefix).host;
|
|
193
|
+
} catch (_e) {
|
|
194
|
+
host = '';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { token, username, host };
|
|
198
|
+
},
|
|
199
|
+
|
|
130
200
|
/**
|
|
131
201
|
* Check if the browser is running on a mobile device or tablet
|
|
132
202
|
* @memberof pryv.utils
|
|
@@ -251,6 +321,18 @@ utils.buildPryvApiEndpoint = utils.buildAPIEndpoint;
|
|
|
251
321
|
* @typedef {string} APIEndpoint
|
|
252
322
|
*/
|
|
253
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Decomposed form of an APIEndpoint, returned by `decomposeAPIEndpoint`.
|
|
326
|
+
* `token` and `username` are `null` when the endpoint doesn't carry a
|
|
327
|
+
* token / doesn't match the `service.info.api` template. `host` is the
|
|
328
|
+
* canonical platform host (no `<username>.` subdomain prefix in
|
|
329
|
+
* subdomain-style deployments).
|
|
330
|
+
* @typedef {Object} DecomposedAPIEndpoint
|
|
331
|
+
* @property {string|null} token
|
|
332
|
+
* @property {string|null} username
|
|
333
|
+
* @property {string} host
|
|
334
|
+
*/
|
|
335
|
+
|
|
254
336
|
/**
|
|
255
337
|
* Common Meta are returned by each standard call on the API https://api.pryv.com/reference/#in-method-results
|
|
256
338
|
* @typedef {Object} CommonMeta
|
package/test/utils.test.js
CHANGED
|
@@ -103,4 +103,41 @@ describe('[UTLX] utils', function () {
|
|
|
103
103
|
expect(err.data.currentSerial).to.equal(2);
|
|
104
104
|
expect(err.name).to.equal('StaleAccessIdError');
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
describe('[UTLM] decomposeAPIEndpoint', function () {
|
|
108
|
+
it('[UTLMA] subdomain template — host strips the {username}. prefix', function () {
|
|
109
|
+
const r = pryv.utils.decomposeAPIEndpoint(
|
|
110
|
+
'https://t0k3n@alice.pryv.me/',
|
|
111
|
+
'https://{username}.pryv.me/'
|
|
112
|
+
);
|
|
113
|
+
expect(r).to.eql({ token: 't0k3n', username: 'alice', host: 'pryv.me' });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('[UTLMB] path-style template — host is the literal hostname[:port]', function () {
|
|
117
|
+
const r = pryv.utils.decomposeAPIEndpoint(
|
|
118
|
+
'http://t0k3n@127.0.0.1:3000/alice/',
|
|
119
|
+
'http://127.0.0.1:3000/{username}/'
|
|
120
|
+
);
|
|
121
|
+
expect(r).to.eql({ token: 't0k3n', username: 'alice', host: '127.0.0.1:3000' });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('[UTLMC] returns username:null when the endpoint doesn\'t match the template', function () {
|
|
125
|
+
const r = pryv.utils.decomposeAPIEndpoint(
|
|
126
|
+
'https://t0k3n@somewhere.else.com/',
|
|
127
|
+
'https://{username}.pryv.me/'
|
|
128
|
+
);
|
|
129
|
+
expect(r.username).to.equal(null);
|
|
130
|
+
expect(r.token).to.equal('t0k3n');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('[UTLMD] returns token:null when endpoint carries no token', function () {
|
|
134
|
+
const r = pryv.utils.decomposeAPIEndpoint(
|
|
135
|
+
'https://alice.pryv.me/',
|
|
136
|
+
'https://{username}.pryv.me/'
|
|
137
|
+
);
|
|
138
|
+
expect(r.token).to.equal(null);
|
|
139
|
+
expect(r.username).to.equal('alice');
|
|
140
|
+
expect(r.host).to.equal('pryv.me');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
106
143
|
});
|
package/src/cmc.js
DELETED
|
@@ -1,348 +0,0 @@
|
|
|
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/test/cmc.test.js
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
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
|
-
});
|