@pryv/delegation 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # Account-delegation client helpers for `pryv`
2
+
3
+ Account-delegation client-side helpers for the [Pryv JavaScript library](https://github.com/pryv/lib-js). Delegation lets one account (the **delegate**, "A") act on behalf of another (the **controlled** account, "B") through a delegate personal-access token (PAT) minted on B — for example a parent running a child's account, or a co-guardian added to an existing one. It is backed by the open-pryv.io `delegation` plugin (`delegations.*` API family).
4
+
5
+
6
+ ## Usage
7
+
8
+ `@pryv/delegation` is a **sibling package** to `pryv` — imported separately, not attached to the `pryv` namespace. It uses a standard personal `pryv.Connection` to talk to the server.
9
+
10
+
11
+ ### Importing
12
+
13
+ #### NPM
14
+
15
+ `npm install --save pryv @pryv/delegation`, then in your code:
16
+
17
+ ```js
18
+ const pryv = require('pryv');
19
+ const { Delegation } = require('@pryv/delegation');
20
+ ```
21
+
22
+ `@pryv/delegation` requires `pryv@^3.3.0` as a peer dependency.
23
+
24
+ #### `<script>` tag
25
+
26
+ `pryv-delegation.js` must be loaded **after** `pryv.js`. In browser bundles, pass the `pryv` module explicitly (needed by `openControlled`, which constructs a `Connection`):
27
+
28
+ ```js
29
+ const delegation = Delegation.fromConnection(conn, { pryv: window.pryv });
30
+ ```
31
+
32
+
33
+ ## Concepts
34
+
35
+ A delegation relationship has two sides:
36
+
37
+ - **B — the controlled account.** Invites a delegate (`requestAttach`), lists its delegates (`listDelegates`), and is the **only** party that can authoritatively remove one (`detachDelegate`). Teardown requires a **genuine login** on B (see below).
38
+ - **A — the delegate.** Accepts or refuses an invite (`acceptAttach` / `refuseAttach`), lists the accounts it controls (`listControlled`), mints a token for one (`getToken` / `openControlled`), and can create brand-new controlled accounts (`createAccount`).
39
+
40
+ Every method maps to exactly one `delegations.*` API method, issued over the connection's batch call.
41
+
42
+
43
+ ## Surface
44
+
45
+ Construct a client by wrapping a personal `pryv.Connection`:
46
+
47
+ ```js
48
+ const delegation = Delegation.fromConnection(conn);
49
+ ```
50
+
51
+ ### B side (the controlled account)
52
+
53
+ ```js
54
+ // Invite a delegate. Returns the pending invite record.
55
+ const invite = await delegation.requestAttach('parent-b');
56
+ // invite = { relId, delegate: { username }, status: 'invite', requestedAt, expiresAt }
57
+
58
+ // List your delegates (status: 'invite' | 'active').
59
+ const delegates = await delegation.listDelegates();
60
+ // [ { relId, delegate: { username, hostSlug }, status, requestedAt, activatedAt?, lastTokenIssuedAt? } ]
61
+
62
+ // Cancel a pending invite you issued (genuine-login-gated, see below).
63
+ await delegation.cancelInvite('parent-b');
64
+
65
+ // Remove a delegate — the authoritative teardown (genuine-login-gated).
66
+ await delegation.detachDelegate('parent-b');
67
+ ```
68
+
69
+ ### A side (the delegate)
70
+
71
+ ```js
72
+ // Accept / refuse an invite you received.
73
+ const rel = await delegation.acceptAttach('kid-account');
74
+ // rel = { relId, controlled: { username }, status: 'active', activatedAt }
75
+ await delegation.refuseAttach('kid-account');
76
+
77
+ // List the accounts you control (status: 'invite' | 'active' | 'stale').
78
+ const controlled = await delegation.listControlled();
79
+ // [ { relId, controlled: { username, hostSlug }, status, requestedAt, activatedAt? } ]
80
+
81
+ // Dismiss a local 'stale' mirror (housekeeping — removes no authority).
82
+ await delegation.dismissControlled('old-account');
83
+
84
+ // Mint a delegate token for a controlled account.
85
+ const { token, apiEndpoint } = await delegation.getToken('kid-account');
86
+
87
+ // One-call "act as the controlled account": get a ready Connection onto B.
88
+ const kidConn = await delegation.openControlled('kid-account');
89
+ const events = await kidConn.apiOne('events.get', { limit: 20 }, 'events');
90
+
91
+ // Create a brand-new controlled account, active at birth.
92
+ const created = await delegation.createAccount({
93
+ username: 'kid-account',
94
+ email: 'guardian+kid@example.com', // optional
95
+ password: 'a-strong-password', // optional (random if omitted)
96
+ core: 'core-2' // optional target core (default: your own)
97
+ });
98
+ // created = { delegation: { relId, controlled: { username, hostSlug }, status: 'active', activatedAt }, apiEndpoint? }
99
+ ```
100
+
101
+ `createAccount` with **no** `email`/`password` yields an account reachable only through delegates until a delegate sets a password.
102
+
103
+
104
+ ## Detach requires a genuine login on B
105
+
106
+ `detachDelegate` and `cancelInvite` remove a delegation relationship, so they require the account owner to be **genuinely logged in** to the controlled account — a delegate PAT or a control token is rejected. A delegated session therefore cannot walk away with, or tear down, the relationship on its own.
107
+
108
+ The rejection surfaces as a typed [`DelegationError`](#typed-errors) with `id === delegationErrorIds.GENUINE_LOGIN_REQUIRED`. Surface it distinctly in your UI:
109
+
110
+ ```js
111
+ const { errorIds: delegationErrorIds } = require('@pryv/delegation');
112
+
113
+ try {
114
+ await delegation.detachDelegate('parent-b');
115
+ } catch (err) {
116
+ if (err.id === delegationErrorIds.GENUINE_LOGIN_REQUIRED) {
117
+ // Prompt the account owner to sign in directly (not via a delegated
118
+ // session) before retrying the detach.
119
+ } else {
120
+ throw err;
121
+ }
122
+ }
123
+ ```
124
+
125
+
126
+ ## Typed errors
127
+
128
+ When a `delegations.*` call rejects with a `delegation-*` id, the method throws a `DelegationError` whose `.id` is one of `errorIds` (a stable, kebab-case mirror of the server-side catalogue). Match on the constant instead of parsing `error.message`. Non-delegation errors (transport, validation, auth) propagate unchanged.
129
+
130
+ ```js
131
+ const { DelegationError, errorIds } = require('@pryv/delegation');
132
+ ```
133
+
134
+ | Constant | String | When |
135
+ |---|---|---|
136
+ | `GENUINE_LOGIN_REQUIRED` | `delegation-genuine-login-required` | `detachDelegate` / `cancelInvite` called without a genuine B login. |
137
+ | `NOT_ACTIVE` | `delegation-not-active` | `getToken` on a torn-down / non-active relationship (mirror flips to `stale`). |
138
+ | `NOT_FOUND` | `delegation-not-found` | No relationship for the given username. |
139
+ | `ALREADY_EXISTS` | `delegation-already-exists` | `requestAttach` duplicates an existing invite/relationship. |
140
+ | `UNKNOWN_USERNAME` | `delegation-unknown-username` | The target username does not resolve to an account. |
141
+ | `SELF_NOT_ALLOWED` | `delegation-self-not-allowed` | An account may not delegate to itself. |
142
+ | `INVITE_EXPIRED` | `delegation-invite-expired` | `acceptAttach` on an expired invite. |
143
+ | `USERNAME_TAKEN` | `delegation-username-taken` | `createAccount` username already claimed. |
144
+ | `UNKNOWN_CORE` | `delegation-unknown-core` | `createAccount` target `core` not known to the platform. |
145
+ | `CREATION_FAILED` | `delegation-creation-failed` | Account creation failed (rolled back). |
146
+ | `DELIVERY_FAILED` | `delegation-delivery-failed` | Cross-core delivery to the counterparty core failed. |
147
+ | `MIRROR_NOT_STALE` | `delegation-mirror-not-stale` | `dismissControlled` on a mirror that is not `stale`. |
148
+ | `PERSONAL_TOKEN_REQUIRED` | `delegation-personal-token-required` | A personal token was required but an app/shared token was used. |
149
+ | `DELEGATE_MISMATCH` | `delegation-delegate-mismatch` | Delegate identity does not match the recorded relationship. |
150
+
151
+ The `STATUS` export mirrors the relationship-status enum used in list entries: `STATUS.INVITE`, `STATUS.ACTIVE`, `STATUS.STALE`.
152
+
153
+
154
+ ## Example
155
+
156
+ See [`examples/delegation-usage.js`](examples/delegation-usage.js) for a runnable end-to-end sample (parent creates a child account, mints a token, acts as the child, then detaches).
157
+
158
+
159
+ ## Contributing
160
+
161
+ See the [Pryv JavaScript library README](https://github.com/pryv/lib-js#contributing)
162
+
163
+
164
+ ## License
165
+
166
+ [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ /**
7
+ * @pryv/delegation — end-to-end usage example.
8
+ *
9
+ * A guardian ("A") creates a controlled account for a child ("B"), acts on
10
+ * that account through a delegate token, then tears the relationship down.
11
+ * Run with a real personal API endpoint:
12
+ *
13
+ * node delegation-usage.js 'https://<token>@<username>.pryv.me/'
14
+ *
15
+ * The token must be a PERSONAL access token on the guardian's account.
16
+ */
17
+
18
+ const pryv = require('pryv');
19
+ const { Delegation, errorIds } = require('@pryv/delegation');
20
+
21
+ async function main () {
22
+ const guardianApiEndpoint = process.argv[2];
23
+ if (guardianApiEndpoint == null) {
24
+ console.error('Usage: node delegation-usage.js <guardian personal apiEndpoint>');
25
+ process.exit(1);
26
+ }
27
+
28
+ const guardianConn = new pryv.Connection(guardianApiEndpoint);
29
+ const delegation = Delegation.fromConnection(guardianConn);
30
+
31
+ // 1. Create a brand-new controlled account (active at birth). Omit the
32
+ // password to make it reachable only through delegates.
33
+ const childUsername = 'kid-' + Date.now();
34
+ const created = await delegation.createAccount({ username: childUsername });
35
+ console.log('created controlled account:', created.delegation.controlled.username,
36
+ '(status:', created.delegation.status + ')');
37
+
38
+ // 2. List the accounts we control.
39
+ const controlled = await delegation.listControlled();
40
+ console.log('controlled accounts:', controlled.map((c) => c.controlled.username + ' [' + c.status + ']'));
41
+
42
+ // 3. Act as the child account in one call — a ready Connection onto B.
43
+ const childConn = await delegation.openControlled(childUsername);
44
+ const created2 = await childConn.apiOne('events.create', {
45
+ streamIds: ['diary'],
46
+ type: 'note/txt',
47
+ content: 'first note, written by the guardian on the child account'
48
+ }, 'event');
49
+ console.log('wrote event on the child account:', created2.id);
50
+
51
+ // 4. (Optional) Add a co-guardian by inviting another account as a delegate.
52
+ // Uncomment with a real second username:
53
+ // const invite = await delegation.requestAttach('other-parent');
54
+ // console.log('invited co-guardian, invite expires at', invite.expiresAt);
55
+
56
+ // 5. Detach requires a GENUINE login on the controlled account. From this
57
+ // guardian (delegate) connection it is refused — surface it distinctly.
58
+ try {
59
+ await delegation.detachDelegate('other-parent');
60
+ } catch (err) {
61
+ if (err.id === errorIds.GENUINE_LOGIN_REQUIRED) {
62
+ console.log('detach refused: the account owner must log in directly to remove a delegate');
63
+ } else if (err.id === errorIds.NOT_FOUND) {
64
+ console.log('detach: no such delegate (expected in this sample)');
65
+ } else {
66
+ throw err;
67
+ }
68
+ }
69
+ }
70
+
71
+ main().catch((err) => {
72
+ console.error('delegation example failed:', err.id || '', err.message);
73
+ process.exit(1);
74
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@pryv/delegation",
3
+ "version": "3.11.0",
4
+ "description": "Account-delegation client helpers for Pryv.io",
5
+ "keywords": [
6
+ "Pryv",
7
+ "Pryv.io",
8
+ "Delegation",
9
+ "Account",
10
+ "Guardian"
11
+ ],
12
+ "homepage": "https://github.com/pryv/lib-js/tree/master/components/pryv-delegation#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/pryv/lib-js/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git://github.com/pryv/lib-js.git"
19
+ },
20
+ "license": "BSD-3-Clause",
21
+ "author": "Pryv <info@pryv.com> (https://pryv.com)",
22
+ "main": "src/index.js",
23
+ "types": "src/index.d.ts",
24
+ "engines": {
25
+ "node": ">=20.0.0"
26
+ },
27
+ "peerDependencies": {
28
+ "pryv": "^3.3.0"
29
+ }
30
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,149 @@
1
+ declare module '@pryv/delegation' {
2
+ // --- Relationship status ---
3
+ export type RelationshipStatus = 'invite' | 'active' | 'stale';
4
+
5
+ export const STATUS: {
6
+ readonly INVITE: 'invite';
7
+ readonly ACTIVE: 'active';
8
+ readonly STALE: 'stale';
9
+ };
10
+
11
+ // --- Error catalogue ---
12
+ export type DelegationErrorId =
13
+ | 'delegation-clientdata-forbidden'
14
+ | 'delegation-managed-resource'
15
+ | 'delegation-reserved-stream'
16
+ | 'delegation-unknown-username'
17
+ | 'delegation-self-not-allowed'
18
+ | 'delegation-delegate-mismatch'
19
+ | 'delegation-already-exists'
20
+ | 'delegation-delivery-failed'
21
+ | 'delegation-genuine-login-required'
22
+ | 'delegation-not-found'
23
+ | 'delegation-invite-expired'
24
+ | 'delegation-not-active'
25
+ | 'delegation-username-taken'
26
+ | 'delegation-unknown-core'
27
+ | 'delegation-creation-failed'
28
+ | 'delegation-personal-token-required'
29
+ | 'delegation-mirror-not-stale';
30
+
31
+ export const errorIds: {
32
+ readonly CLIENTDATA_FORBIDDEN: 'delegation-clientdata-forbidden';
33
+ readonly MANAGED_RESOURCE: 'delegation-managed-resource';
34
+ readonly RESERVED_STREAM: 'delegation-reserved-stream';
35
+ readonly UNKNOWN_USERNAME: 'delegation-unknown-username';
36
+ readonly SELF_NOT_ALLOWED: 'delegation-self-not-allowed';
37
+ readonly DELEGATE_MISMATCH: 'delegation-delegate-mismatch';
38
+ readonly ALREADY_EXISTS: 'delegation-already-exists';
39
+ readonly DELIVERY_FAILED: 'delegation-delivery-failed';
40
+ readonly GENUINE_LOGIN_REQUIRED: 'delegation-genuine-login-required';
41
+ readonly NOT_FOUND: 'delegation-not-found';
42
+ readonly INVITE_EXPIRED: 'delegation-invite-expired';
43
+ readonly NOT_ACTIVE: 'delegation-not-active';
44
+ readonly USERNAME_TAKEN: 'delegation-username-taken';
45
+ readonly UNKNOWN_CORE: 'delegation-unknown-core';
46
+ readonly CREATION_FAILED: 'delegation-creation-failed';
47
+ readonly PERSONAL_TOKEN_REQUIRED: 'delegation-personal-token-required';
48
+ readonly MIRROR_NOT_STALE: 'delegation-mirror-not-stale';
49
+ };
50
+
51
+ /** Typed delegation failure surfaced by {@link Delegation} methods. */
52
+ export class DelegationError extends Error {
53
+ constructor(message: string, id: DelegationErrorId | string, cause?: any, data?: any);
54
+ readonly name: 'DelegationError';
55
+ readonly id: DelegationErrorId | string;
56
+ readonly cause?: any;
57
+ readonly data?: any;
58
+ }
59
+
60
+ // --- Records ---
61
+
62
+ export type DelegateIdentity = {
63
+ username: string;
64
+ /** Host with `.` replaced by `-`. */
65
+ hostSlug: string;
66
+ };
67
+
68
+ /** An entry of `listDelegates()` (B side). */
69
+ export type DelegateRecord = {
70
+ relId: string;
71
+ delegate: DelegateIdentity;
72
+ status: RelationshipStatus;
73
+ requestedAt: number;
74
+ activatedAt?: number | null;
75
+ lastTokenIssuedAt?: number | null;
76
+ };
77
+
78
+ /** An entry of `listControlled()` (A side). */
79
+ export type ControlledRecord = {
80
+ relId: string;
81
+ controlled: DelegateIdentity;
82
+ status: RelationshipStatus;
83
+ requestedAt: number;
84
+ activatedAt?: number | null;
85
+ };
86
+
87
+ /**
88
+ * The `delegation` record returned by `requestAttach` / `acceptAttach` /
89
+ * `createAccount`. Shape varies by call: an invite carries `delegate` +
90
+ * `expiresAt`; an activation carries `controlled` + `activatedAt`.
91
+ */
92
+ export type DelegationRecord = {
93
+ relId: string;
94
+ status: RelationshipStatus;
95
+ delegate?: { username: string; hostSlug?: string };
96
+ controlled?: { username: string; hostSlug?: string };
97
+ requestedAt?: number;
98
+ expiresAt?: number;
99
+ activatedAt?: number;
100
+ };
101
+
102
+ export type TokenResult = {
103
+ token: string;
104
+ apiEndpoint: string;
105
+ };
106
+
107
+ export type CreateAccountParams = {
108
+ username: string;
109
+ email?: string;
110
+ password?: string;
111
+ core?: string;
112
+ language?: string;
113
+ };
114
+
115
+ export type CreateAccountResult = {
116
+ delegation: DelegationRecord;
117
+ apiEndpoint?: string;
118
+ };
119
+
120
+ export type DelegationOptions = {
121
+ /** Explicit `pryv` module (needed by `openControlled` in browser bundles). */
122
+ pryv?: any;
123
+ };
124
+
125
+ /**
126
+ * Client for the `delegations.*` API family, bound to one personal
127
+ * `pryv.Connection`.
128
+ */
129
+ export class Delegation {
130
+ constructor(connection: any, opts?: DelegationOptions);
131
+ static fromConnection(connection: any, opts?: DelegationOptions): Delegation;
132
+ readonly connection: any;
133
+
134
+ // B side (the controlled account)
135
+ requestAttach(delegateUsername: string): Promise<DelegationRecord>;
136
+ cancelInvite(delegateUsername: string): Promise<void>;
137
+ listDelegates(): Promise<DelegateRecord[]>;
138
+ detachDelegate(delegateUsername: string): Promise<void>;
139
+
140
+ // A side (the delegate)
141
+ acceptAttach(controlledUsername: string): Promise<DelegationRecord>;
142
+ refuseAttach(controlledUsername: string): Promise<void>;
143
+ listControlled(): Promise<ControlledRecord[]>;
144
+ dismissControlled(controlledUsername: string): Promise<void>;
145
+ getToken(controlledUsername: string): Promise<TokenResult>;
146
+ openControlled(controlledUsername: string): Promise<any>;
147
+ createAccount(params: CreateAccountParams): Promise<CreateAccountResult>;
148
+ }
149
+ }
package/src/index.js ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ /**
7
+ * @pryv/delegation — account-delegation client helpers.
8
+ *
9
+ * Thin, typed wrapper over a personal `pryv.Connection` for the server-side
10
+ * `delegations.*` API family (open-pryv.io `delegation` plugin). Account
11
+ * delegation lets one account (the "delegate", A) act on behalf of another
12
+ * (the "controlled" account, B) through a delegate personal-access token (PAT)
13
+ * minted on B — e.g. a parent running a child's account, or a co-guardian
14
+ * added to an existing one.
15
+ *
16
+ * Two sides of one relationship:
17
+ * - B (the controlled account) invites a delegate, and is the ONLY party
18
+ * that can authoritatively detach one (genuine login required).
19
+ * - A (the delegate) accepts/refuses the invite, lists the accounts it
20
+ * controls, mints a token for one, and can create brand-new controlled
21
+ * accounts.
22
+ *
23
+ * Every method here maps to exactly one `delegations.*` API method, issued
24
+ * through `connection.apiOne(...)` (batch call). Errors carrying a
25
+ * `delegation-*` id surface as a typed {@link DelegationError} whose `.id`
26
+ * matches one of {@link errorIds}, so callers branch on a stable constant
27
+ * instead of parsing English `error.message`.
28
+ *
29
+ * See server-side `open-pryv.io/components/delegation/` +
30
+ * `components/api-server/src/{routes,methods}/delegations.ts`.
31
+ */
32
+
33
+ // --- Relationship status ---------------------------------------------------
34
+ // Mirrors the server `STATUS` enum (components/delegation/src/constants.ts).
35
+ // `listDelegates()` / `listControlled()` entries carry one of these.
36
+ const STATUS = Object.freeze({
37
+ /** A pending invite: sent (B side) / received (A side), not yet accepted. */
38
+ INVITE: 'invite',
39
+ /** An active relationship: the delegate holds (or can mint) a live PAT. */
40
+ ACTIVE: 'active',
41
+ /**
42
+ * A's local mirror discovered the relationship was torn down on B (a token
43
+ * mint answered 401/403). Advisory only — carries no authority; the row is
44
+ * user-dismissable via `dismissControlled(...)`.
45
+ */
46
+ STALE: 'stale'
47
+ });
48
+
49
+ // --- Typed error catalogue -------------------------------------------------
50
+ // Mirror of the server-side DelegationErrorIds
51
+ // (components/delegation/src/errorIds.ts). Match on these constants when a
52
+ // `delegations.*` call rejects, instead of parsing `error.message`.
53
+ const errorIds = Object.freeze({
54
+ // Guard hooks (a generic accesses/events/streams write hit a plugin-owned
55
+ // namespace — should not occur through this client, surfaced for safety).
56
+ CLIENTDATA_FORBIDDEN: 'delegation-clientdata-forbidden',
57
+ MANAGED_RESOURCE: 'delegation-managed-resource',
58
+ RESERVED_STREAM: 'delegation-reserved-stream',
59
+ // Handshake / lifecycle.
60
+ UNKNOWN_USERNAME: 'delegation-unknown-username',
61
+ SELF_NOT_ALLOWED: 'delegation-self-not-allowed',
62
+ DELEGATE_MISMATCH: 'delegation-delegate-mismatch',
63
+ ALREADY_EXISTS: 'delegation-already-exists',
64
+ DELIVERY_FAILED: 'delegation-delivery-failed',
65
+ /**
66
+ * The operation (detach / cancel) requires a genuine, freshly-authenticated
67
+ * login on the controlled account — a delegate PAT or control token cannot
68
+ * remove a delegation relationship. The UI must prompt the account owner to
69
+ * log in directly before retrying.
70
+ */
71
+ GENUINE_LOGIN_REQUIRED: 'delegation-genuine-login-required',
72
+ NOT_FOUND: 'delegation-not-found',
73
+ INVITE_EXPIRED: 'delegation-invite-expired',
74
+ NOT_ACTIVE: 'delegation-not-active',
75
+ USERNAME_TAKEN: 'delegation-username-taken',
76
+ UNKNOWN_CORE: 'delegation-unknown-core',
77
+ CREATION_FAILED: 'delegation-creation-failed',
78
+ PERSONAL_TOKEN_REQUIRED: 'delegation-personal-token-required',
79
+ MIRROR_NOT_STALE: 'delegation-mirror-not-stale'
80
+ });
81
+
82
+ const DELEGATION_ID_VALUES = new Set(Object.values(errorIds));
83
+
84
+ /**
85
+ * Typed error surfaced by {@link Delegation} methods when a `delegations.*`
86
+ * call rejects with a `delegation-*` id. `id` is the stable kebab-case string
87
+ * (one of {@link errorIds}); `cause` is the underlying `pryv` error; `data`
88
+ * is the server error's `data` payload when present.
89
+ */
90
+ class DelegationError extends Error {
91
+ constructor (message, id, cause, data) {
92
+ super(message);
93
+ this.name = 'DelegationError';
94
+ this.id = id;
95
+ if (cause !== undefined) this.cause = cause;
96
+ if (data !== undefined) this.data = data;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Extract a Pryv API `error.id` from a thrown error, across the shapes the
102
+ * `pryv` client uses. `connection.apiOne` throws a `PryvError` whose
103
+ * `innerObject` is the batch call's `error` object (`{ id, message, data }`);
104
+ * `PryvError.fromApiResponse` sets `.id` / `.response` directly. Check all.
105
+ */
106
+ function errorIdFrom (err) {
107
+ if (err == null) return null;
108
+ if (typeof err.id === 'string') return err.id;
109
+ const inner = err.innerObject;
110
+ if (inner != null) {
111
+ if (typeof inner.id === 'string') return inner.id;
112
+ if (inner.error != null && typeof inner.error.id === 'string') return inner.error.id;
113
+ }
114
+ const resp = err.response;
115
+ if (resp != null && resp.body != null && resp.body.error != null && typeof resp.body.error.id === 'string') {
116
+ return resp.body.error.id;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /** Pull the server error `data` payload from a thrown `pryv` error, if any. */
122
+ function errorDataFrom (err) {
123
+ if (err == null) return undefined;
124
+ const inner = err.innerObject;
125
+ if (inner != null) {
126
+ if (inner.data !== undefined) return inner.data;
127
+ if (inner.error != null && inner.error.data !== undefined) return inner.error.data;
128
+ }
129
+ const resp = err.response;
130
+ if (resp != null && resp.body != null && resp.body.error != null) return resp.body.error.data;
131
+ return undefined;
132
+ }
133
+
134
+ /**
135
+ * Map a thrown `pryv` error to a {@link DelegationError} when it carries a
136
+ * `delegation-*` id; otherwise return it unchanged so transport / validation
137
+ * errors propagate as-is.
138
+ */
139
+ function toDelegationError (err) {
140
+ const id = errorIdFrom(err);
141
+ if (id != null && (DELEGATION_ID_VALUES.has(id) || id.startsWith('delegation-'))) {
142
+ const message = (err && err.message) || id;
143
+ return new DelegationError(message, id, err, errorDataFrom(err));
144
+ }
145
+ return err;
146
+ }
147
+
148
+ /**
149
+ * Client for the `delegations.*` API family, bound to one personal
150
+ * `pryv.Connection`. Construct via {@link Delegation.fromConnection}.
151
+ */
152
+ class Delegation {
153
+ /**
154
+ * @param {Object} connection a personal `pryv.Connection`.
155
+ * @param {Object} [opts]
156
+ * @param {Object} [opts.pryv] explicit `pryv` module (otherwise resolved
157
+ * via `require('pryv')`; pass it in browser bundles where `require` is
158
+ * unavailable — needed only by `openControlled`).
159
+ */
160
+ constructor (connection, opts) {
161
+ if (connection == null) throw new Error('Delegation: a pryv.Connection is required');
162
+ this.connection = connection;
163
+ this._pryvModule = (opts && opts.pryv) || null;
164
+ }
165
+
166
+ /**
167
+ * Wrap a personal `pryv.Connection` in a {@link Delegation} client.
168
+ * @param {Object} connection a personal `pryv.Connection`.
169
+ * @param {Object} [opts] see the constructor.
170
+ * @returns {Delegation}
171
+ */
172
+ static fromConnection (connection, opts) {
173
+ return new Delegation(connection, opts);
174
+ }
175
+
176
+ /** @private Resolve the `pryv` module (explicit opt or lazy require). */
177
+ _getPryv () {
178
+ if (this._pryvModule != null) return this._pryvModule;
179
+ this._pryvModule = require('pryv');
180
+ return this._pryvModule;
181
+ }
182
+
183
+ /** @private One batch call, remapping `delegation-*` errors to DelegationError. */
184
+ async _apiOne (method, params, expectedKey) {
185
+ try {
186
+ return await this.connection.apiOne(method, params || {}, expectedKey);
187
+ } catch (err) {
188
+ throw toDelegationError(err);
189
+ }
190
+ }
191
+
192
+ // ---- B side (the controlled account) ----------------------------------
193
+
194
+ /**
195
+ * B invites a delegate. `POST /delegations/attach-request`.
196
+ * A delegate PAT acting as B MAY call this (that is how a delegate adds a
197
+ * co-delegate).
198
+ *
199
+ * @param {string} delegateUsername the account to invite as delegate.
200
+ * @returns {Promise<DelegationRecord>} the pending invite record
201
+ * `{ relId, delegate:{username}, status:'invite', requestedAt, expiresAt }`.
202
+ */
203
+ async requestAttach (delegateUsername) {
204
+ return await this._apiOne('delegations.requestAttach', { delegateUsername }, 'delegation');
205
+ }
206
+
207
+ /**
208
+ * B cancels a pending invite it issued. `POST /delegations/delegates/{username}/cancel`.
209
+ * Genuine-login-gated on B (a delegate PAT cannot cancel B's invites) →
210
+ * may throw {@link DelegationError} `delegation-genuine-login-required`.
211
+ *
212
+ * @param {string} delegateUsername the invited delegate to cancel.
213
+ * @returns {Promise<void>}
214
+ */
215
+ async cancelInvite (delegateUsername) {
216
+ await this._apiOne('delegations.cancelInvite', { username: delegateUsername });
217
+ }
218
+
219
+ /**
220
+ * List the delegates of the connected (B) account. `GET /delegations/delegates`.
221
+ *
222
+ * @returns {Promise<DelegateRecord[]>} each `{ relId, delegate:{username,
223
+ * hostSlug}, status, requestedAt, activatedAt?, lastTokenIssuedAt? }`.
224
+ */
225
+ async listDelegates () {
226
+ return await this._apiOne('delegations.listDelegates', {}, 'delegates');
227
+ }
228
+
229
+ /**
230
+ * B detaches a delegate — THE authoritative teardown. `DELETE
231
+ * /delegations/delegates/{username}`. Removes the delegate's PAT + all
232
+ * marker accesses on B (active), or cancels the pending invite.
233
+ *
234
+ * **Requires a genuine login on B.** A delegate PAT or control token is
235
+ * rejected with {@link DelegationError} `delegation-genuine-login-required`
236
+ * — surface it distinctly so the UI can explain the account owner must log
237
+ * in directly (not through a delegated session) to remove a delegate.
238
+ *
239
+ * @param {string} delegateUsername the delegate to detach.
240
+ * @returns {Promise<void>}
241
+ */
242
+ async detachDelegate (delegateUsername) {
243
+ await this._apiOne('delegations.detachDelegate', { username: delegateUsername });
244
+ }
245
+
246
+ // ---- A side (the delegate) --------------------------------------------
247
+
248
+ /**
249
+ * A accepts a pending invite from a controlled account.
250
+ * `POST /delegations/controlled/{username}/accept`.
251
+ *
252
+ * @param {string} controlledUsername the inviting account.
253
+ * @returns {Promise<DelegationRecord>} `{ relId, controlled:{username},
254
+ * status:'active', activatedAt }`.
255
+ */
256
+ async acceptAttach (controlledUsername) {
257
+ return await this._apiOne('delegations.acceptAttach', { username: controlledUsername }, 'delegation');
258
+ }
259
+
260
+ /**
261
+ * A refuses a pending invite. `POST /delegations/controlled/{username}/refuse`.
262
+ * A unilateral decline (not a detach) — gated on A's personal token.
263
+ *
264
+ * @param {string} controlledUsername the inviting account.
265
+ * @returns {Promise<void>}
266
+ */
267
+ async refuseAttach (controlledUsername) {
268
+ await this._apiOne('delegations.refuseAttach', { username: controlledUsername });
269
+ }
270
+
271
+ /**
272
+ * List the accounts the connected (A) account controls.
273
+ * `GET /delegations/controlled`.
274
+ *
275
+ * @returns {Promise<ControlledRecord[]>} each `{ relId, controlled:{username,
276
+ * hostSlug}, status, requestedAt, activatedAt? }`. `status` may be
277
+ * `'stale'` — see {@link dismissControlled}.
278
+ */
279
+ async listControlled () {
280
+ return await this._apiOne('delegations.listControlled', {}, 'controlled');
281
+ }
282
+
283
+ /**
284
+ * A dismisses a local `stale` mirror row (housekeeping). `DELETE
285
+ * /delegations/controlled/{username}`. Removes no authority and never
286
+ * touches B; rejects a non-stale mirror with `delegation-mirror-not-stale`.
287
+ *
288
+ * @param {string} controlledUsername the stale controlled account to drop.
289
+ * @returns {Promise<void>}
290
+ */
291
+ async dismissControlled (controlledUsername) {
292
+ await this._apiOne('delegations.dismissControlled', { username: controlledUsername });
293
+ }
294
+
295
+ /**
296
+ * A mints (or refreshes) a delegate PAT for an active controlled account.
297
+ * `POST /delegations/controlled/{username}/token`. On a torn-down
298
+ * relationship the mirror flips to `stale` and this throws
299
+ * {@link DelegationError} `delegation-not-active`.
300
+ *
301
+ * @param {string} controlledUsername the controlled account.
302
+ * @returns {Promise<{token: string, apiEndpoint: string}>} the delegate PAT
303
+ * and the controlled account's API base — use them to talk to B directly.
304
+ */
305
+ async getToken (controlledUsername) {
306
+ const res = await this._apiOne('delegations.getToken', { username: controlledUsername });
307
+ return { token: res.token, apiEndpoint: res.apiEndpoint };
308
+ }
309
+
310
+ /**
311
+ * One-call "act as the controlled account": mint a token and return a ready
312
+ * `pryv.Connection` onto the controlled account (B).
313
+ *
314
+ * Composes {@link getToken} + connection construction. Needs the `pryv`
315
+ * module — resolved via `require('pryv')` in Node, or the `opts.pryv` passed
316
+ * to {@link Delegation.fromConnection} in a browser bundle.
317
+ *
318
+ * @param {string} controlledUsername the controlled account.
319
+ * @returns {Promise<Object>} a `pryv.Connection` authenticated as B.
320
+ */
321
+ async openControlled (controlledUsername) {
322
+ const { token, apiEndpoint } = await this.getToken(controlledUsername);
323
+ const pryv = this._getPryv();
324
+ // getToken returns the token + the controlled core's API base separately;
325
+ // fold them into one token-bearing apiEndpoint the Connection accepts.
326
+ const { endpoint } = pryv.utils.extractTokenAndAPIEndpoint(apiEndpoint);
327
+ const fullApiEndpoint = pryv.utils.buildAPIEndpoint({ endpoint, token });
328
+ return new pryv.Connection(fullApiEndpoint);
329
+ }
330
+
331
+ /**
332
+ * A creates a brand-new controlled account, active at birth.
333
+ * `POST /delegations/controlled`. `email` / `password` are optional — a
334
+ * password-less account is reachable only through delegates until one sets
335
+ * a password. `core` picks the target core (default = A's own).
336
+ *
337
+ * @param {Object} params
338
+ * @param {string} params.username the new account's username.
339
+ * @param {string} [params.email] optional email.
340
+ * @param {string} [params.password] optional password (random if omitted).
341
+ * @param {string} [params.core] optional target core (id or URL).
342
+ * @param {string} [params.language] optional preferred language.
343
+ * @returns {Promise<{delegation: DelegationRecord, apiEndpoint?: string}>}
344
+ */
345
+ async createAccount (params) {
346
+ if (params == null || typeof params.username !== 'string' || params.username.length === 0) {
347
+ throw new Error('createAccount: params.username is required');
348
+ }
349
+ const body = { username: params.username };
350
+ if (params.email !== undefined) body.email = params.email;
351
+ if (params.password !== undefined) body.password = params.password;
352
+ if (params.core !== undefined) body.core = params.core;
353
+ if (params.language !== undefined) body.language = params.language;
354
+ const res = await this._apiOne('delegations.createAccount', body);
355
+ return { delegation: res.delegation, apiEndpoint: res.apiEndpoint };
356
+ }
357
+ }
358
+
359
+ module.exports = {
360
+ Delegation,
361
+ DelegationError,
362
+ errorIds,
363
+ STATUS
364
+ };
@@ -0,0 +1,386 @@
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 pryv = require('pryv');
8
+ const delegation = require('../src');
9
+ const { Delegation, DelegationError, errorIds, STATUS } = delegation;
10
+
11
+ /**
12
+ * Stub Connection — records (method, params, expectedKey) calls and replays
13
+ * canned responses from a per-method handler. Mirrors enough of the real
14
+ * `pryv.Connection.apiOne` surface for unit tests:
15
+ * apiOne(method, params, expectedKey) → result[expectedKey] (or full result)
16
+ *
17
+ * A handler may return `{ __error: { id, message, data } }` to simulate the
18
+ * real client's throw shape: `apiOne` rejects with a PryvError-like error
19
+ * whose `innerObject` is the batch call's `error` object.
20
+ */
21
+ function makeStubConnection (options) {
22
+ options = options || {};
23
+ const handlers = options.handlers || {};
24
+ const calls = [];
25
+ return {
26
+ calls,
27
+ async apiOne (method, params, expectedKey) {
28
+ calls.push({ method, params, expectedKey });
29
+ const handler = handlers[method];
30
+ if (handler == null) {
31
+ throw new Error('stubConnection: no handler for method "' + method + '"');
32
+ }
33
+ const result = typeof handler === 'function' ? await handler(params, calls.length - 1) : handler;
34
+ if (result && result.__error) {
35
+ const e = new Error(result.__error.message || 'api error');
36
+ e.innerObject = result.__error;
37
+ throw e;
38
+ }
39
+ if (expectedKey != null) {
40
+ if (result == null || result[expectedKey] == null) {
41
+ throw new Error('stubConnection: missing expectedKey "' + expectedKey + '"');
42
+ }
43
+ return result[expectedKey];
44
+ }
45
+ return result;
46
+ }
47
+ };
48
+ }
49
+
50
+ describe('[DELX] @pryv/delegation Level-0 surface', function () {
51
+ describe('[DELXS] STATUS constants', function () {
52
+ it('[DELXSA] exposes the frozen relationship-status enum', function () {
53
+ expect(STATUS).to.be.frozen;
54
+ expect(STATUS.INVITE).to.equal('invite');
55
+ expect(STATUS.ACTIVE).to.equal('active');
56
+ expect(STATUS.STALE).to.equal('stale');
57
+ });
58
+ });
59
+
60
+ describe('[DELXE] errorIds catalogue', function () {
61
+ it('[DELXEA] is frozen + mirrors the server delegation-* ids', function () {
62
+ expect(errorIds).to.be.frozen;
63
+ expect(errorIds.GENUINE_LOGIN_REQUIRED).to.equal('delegation-genuine-login-required');
64
+ expect(errorIds.NOT_ACTIVE).to.equal('delegation-not-active');
65
+ expect(errorIds.ALREADY_EXISTS).to.equal('delegation-already-exists');
66
+ expect(errorIds.USERNAME_TAKEN).to.equal('delegation-username-taken');
67
+ expect(errorIds.UNKNOWN_CORE).to.equal('delegation-unknown-core');
68
+ expect(errorIds.INVITE_EXPIRED).to.equal('delegation-invite-expired');
69
+ expect(errorIds.UNKNOWN_USERNAME).to.equal('delegation-unknown-username');
70
+ expect(errorIds.SELF_NOT_ALLOWED).to.equal('delegation-self-not-allowed');
71
+ expect(errorIds.MIRROR_NOT_STALE).to.equal('delegation-mirror-not-stale');
72
+ });
73
+
74
+ it('[DELXEB] every id is kebab-case under the delegation- namespace', function () {
75
+ for (const id of Object.values(errorIds)) {
76
+ expect(id).to.match(/^delegation-[a-z-]+$/);
77
+ }
78
+ });
79
+ });
80
+
81
+ describe('[DELXC] DelegationError', function () {
82
+ it('[DELXCA] carries id + cause + data', function () {
83
+ const cause = new Error('boom');
84
+ const err = new DelegationError('nope', errorIds.NOT_ACTIVE, cause, { peerStatus: 410 });
85
+ expect(err).to.be.instanceOf(Error);
86
+ expect(err.name).to.equal('DelegationError');
87
+ expect(err.id).to.equal('delegation-not-active');
88
+ expect(err.cause).to.equal(cause);
89
+ expect(err.data).to.deep.equal({ peerStatus: 410 });
90
+ });
91
+ });
92
+
93
+ describe('[DELXF] fromConnection', function () {
94
+ it('[DELXFA] wraps a connection; requires one', function () {
95
+ const conn = makeStubConnection();
96
+ const d = Delegation.fromConnection(conn);
97
+ expect(d).to.be.instanceOf(Delegation);
98
+ expect(d.connection).to.equal(conn);
99
+ expect(() => Delegation.fromConnection(null)).to.throw();
100
+ });
101
+ });
102
+ });
103
+
104
+ describe('[DELM] @pryv/delegation method → endpoint mapping', function () {
105
+ describe('[DELMB] B-side (controlled account)', function () {
106
+ it('[DELMBA] requestAttach posts delegations.requestAttach with delegateUsername', async function () {
107
+ const conn = makeStubConnection({
108
+ handlers: {
109
+ 'delegations.requestAttach': function (params) {
110
+ return {
111
+ delegation: {
112
+ relId: 'rel-1',
113
+ delegate: { username: params.delegateUsername },
114
+ status: 'invite',
115
+ requestedAt: 1000,
116
+ expiresAt: 2000
117
+ }
118
+ };
119
+ }
120
+ }
121
+ });
122
+ const d = Delegation.fromConnection(conn);
123
+ const rec = await d.requestAttach('bob');
124
+ expect(conn.calls).to.have.length(1);
125
+ expect(conn.calls[0].method).to.equal('delegations.requestAttach');
126
+ expect(conn.calls[0].params).to.deep.equal({ delegateUsername: 'bob' });
127
+ expect(conn.calls[0].expectedKey).to.equal('delegation');
128
+ expect(rec.relId).to.equal('rel-1');
129
+ expect(rec.status).to.equal(STATUS.INVITE);
130
+ expect(rec.delegate.username).to.equal('bob');
131
+ });
132
+
133
+ it('[DELMBB] cancelInvite posts delegations.cancelInvite with username', async function () {
134
+ const conn = makeStubConnection({ handlers: { 'delegations.cancelInvite': function () { return {}; } } });
135
+ const d = Delegation.fromConnection(conn);
136
+ await d.cancelInvite('bob');
137
+ expect(conn.calls[0].method).to.equal('delegations.cancelInvite');
138
+ expect(conn.calls[0].params).to.deep.equal({ username: 'bob' });
139
+ });
140
+
141
+ it('[DELMBC] listDelegates returns the delegates array incl. status', async function () {
142
+ const conn = makeStubConnection({
143
+ handlers: {
144
+ 'delegations.listDelegates': function () {
145
+ return {
146
+ delegates: [
147
+ { relId: 'r1', delegate: { username: 'a', hostSlug: 'pryv-me' }, status: 'active', requestedAt: 1, activatedAt: 2 },
148
+ { relId: 'r2', delegate: { username: 'b', hostSlug: 'pryv-me' }, status: 'invite', requestedAt: 3 }
149
+ ]
150
+ };
151
+ }
152
+ }
153
+ });
154
+ const d = Delegation.fromConnection(conn);
155
+ const list = await d.listDelegates();
156
+ expect(conn.calls[0].method).to.equal('delegations.listDelegates');
157
+ expect(conn.calls[0].expectedKey).to.equal('delegates');
158
+ expect(list).to.have.length(2);
159
+ expect(list[0].status).to.equal(STATUS.ACTIVE);
160
+ expect(list[1].status).to.equal(STATUS.INVITE);
161
+ });
162
+
163
+ it('[DELMBD] detachDelegate posts delegations.detachDelegate with username', async function () {
164
+ const conn = makeStubConnection({ handlers: { 'delegations.detachDelegate': function () { return {}; } } });
165
+ const d = Delegation.fromConnection(conn);
166
+ await d.detachDelegate('bob');
167
+ expect(conn.calls[0].method).to.equal('delegations.detachDelegate');
168
+ expect(conn.calls[0].params).to.deep.equal({ username: 'bob' });
169
+ });
170
+ });
171
+
172
+ describe('[DELMA] A-side (delegate)', function () {
173
+ it('[DELMAA] acceptAttach posts delegations.acceptAttach with username', async function () {
174
+ const conn = makeStubConnection({
175
+ handlers: {
176
+ 'delegations.acceptAttach': function (params) {
177
+ return { delegation: { relId: 'rel-9', controlled: { username: params.username }, status: 'active', activatedAt: 5 } };
178
+ }
179
+ }
180
+ });
181
+ const d = Delegation.fromConnection(conn);
182
+ const rec = await d.acceptAttach('kid');
183
+ expect(conn.calls[0].method).to.equal('delegations.acceptAttach');
184
+ expect(conn.calls[0].params).to.deep.equal({ username: 'kid' });
185
+ expect(conn.calls[0].expectedKey).to.equal('delegation');
186
+ expect(rec.status).to.equal(STATUS.ACTIVE);
187
+ expect(rec.controlled.username).to.equal('kid');
188
+ });
189
+
190
+ it('[DELMAB] refuseAttach posts delegations.refuseAttach with username', async function () {
191
+ const conn = makeStubConnection({ handlers: { 'delegations.refuseAttach': function () { return {}; } } });
192
+ const d = Delegation.fromConnection(conn);
193
+ await d.refuseAttach('kid');
194
+ expect(conn.calls[0].method).to.equal('delegations.refuseAttach');
195
+ expect(conn.calls[0].params).to.deep.equal({ username: 'kid' });
196
+ });
197
+
198
+ it('[DELMAC] listControlled returns the controlled array incl. stale status', async function () {
199
+ const conn = makeStubConnection({
200
+ handlers: {
201
+ 'delegations.listControlled': function () {
202
+ return {
203
+ controlled: [
204
+ { relId: 'r1', controlled: { username: 'kid', hostSlug: 'pryv-me' }, status: 'active', requestedAt: 1, activatedAt: 2 },
205
+ { relId: 'r2', controlled: { username: 'old', hostSlug: 'pryv-me' }, status: 'stale', requestedAt: 3 }
206
+ ]
207
+ };
208
+ }
209
+ }
210
+ });
211
+ const d = Delegation.fromConnection(conn);
212
+ const list = await d.listControlled();
213
+ expect(conn.calls[0].method).to.equal('delegations.listControlled');
214
+ expect(conn.calls[0].expectedKey).to.equal('controlled');
215
+ expect(list).to.have.length(2);
216
+ expect(list[1].status).to.equal(STATUS.STALE);
217
+ });
218
+
219
+ it('[DELMAD] dismissControlled posts delegations.dismissControlled with username', async function () {
220
+ const conn = makeStubConnection({ handlers: { 'delegations.dismissControlled': function () { return {}; } } });
221
+ const d = Delegation.fromConnection(conn);
222
+ await d.dismissControlled('old');
223
+ expect(conn.calls[0].method).to.equal('delegations.dismissControlled');
224
+ expect(conn.calls[0].params).to.deep.equal({ username: 'old' });
225
+ });
226
+
227
+ it('[DELMAE] getToken returns { token, apiEndpoint }', async function () {
228
+ const conn = makeStubConnection({
229
+ handlers: {
230
+ 'delegations.getToken': function (params) {
231
+ expect(params).to.deep.equal({ username: 'kid' });
232
+ return { token: 'tok-abc', apiEndpoint: 'https://kid.pryv.me/', extra: 'ignored' };
233
+ }
234
+ }
235
+ });
236
+ const d = Delegation.fromConnection(conn);
237
+ const res = await d.getToken('kid');
238
+ expect(res).to.deep.equal({ token: 'tok-abc', apiEndpoint: 'https://kid.pryv.me/' });
239
+ });
240
+
241
+ it('[DELMAF] createAccount posts delegations.createAccount + returns { delegation, apiEndpoint }', async function () {
242
+ const conn = makeStubConnection({
243
+ handlers: {
244
+ 'delegations.createAccount': function (params) {
245
+ return {
246
+ delegation: { relId: 'rel-new', controlled: { username: params.username, hostSlug: 'pryv-me' }, status: 'active', activatedAt: 9 },
247
+ apiEndpoint: 'https://new.pryv.me/'
248
+ };
249
+ }
250
+ }
251
+ });
252
+ const d = Delegation.fromConnection(conn);
253
+ const res = await d.createAccount({ username: 'newkid', email: 'k@x.io', password: 'p', core: 'core-2' });
254
+ expect(conn.calls[0].method).to.equal('delegations.createAccount');
255
+ expect(conn.calls[0].params).to.deep.equal({ username: 'newkid', email: 'k@x.io', password: 'p', core: 'core-2' });
256
+ expect(res.delegation.status).to.equal(STATUS.ACTIVE);
257
+ expect(res.apiEndpoint).to.equal('https://new.pryv.me/');
258
+ });
259
+
260
+ it('[DELMAG] createAccount omits absent optional fields + requires username', async function () {
261
+ const conn = makeStubConnection({
262
+ handlers: {
263
+ 'delegations.createAccount': function () {
264
+ return { delegation: { relId: 'r', controlled: { username: 'x' }, status: 'active' } };
265
+ }
266
+ }
267
+ });
268
+ const d = Delegation.fromConnection(conn);
269
+ await d.createAccount({ username: 'x' });
270
+ expect(conn.calls[0].params).to.deep.equal({ username: 'x' });
271
+ let threw = false;
272
+ try { await d.createAccount({}); } catch (_e) { threw = true; }
273
+ expect(threw).to.equal(true);
274
+ });
275
+ });
276
+
277
+ describe('[DELMO] openControlled round-trip', function () {
278
+ it('[DELMOA] mints a token then builds a pryv.Connection onto the controlled account', async function () {
279
+ const conn = makeStubConnection({
280
+ handlers: {
281
+ 'delegations.getToken': function () {
282
+ return { token: 'tok-xyz', apiEndpoint: 'https://kid.pryv.me/' };
283
+ }
284
+ }
285
+ });
286
+ // Use the real pryv module so the endpoint composition is exercised end-to-end.
287
+ const d = Delegation.fromConnection(conn, { pryv });
288
+ const controlledConn = await d.openControlled('kid');
289
+ expect(conn.calls[0].method).to.equal('delegations.getToken');
290
+ expect(controlledConn).to.be.instanceOf(pryv.Connection);
291
+ expect(controlledConn.token).to.equal('tok-xyz');
292
+ expect(controlledConn.endpoint).to.equal('https://kid.pryv.me/');
293
+ });
294
+ });
295
+ });
296
+
297
+ describe('[DELE] @pryv/delegation typed error surfacing', function () {
298
+ it('[DELEA] detachDelegate surfaces delegation-genuine-login-required distinctly', async function () {
299
+ const conn = makeStubConnection({
300
+ handlers: {
301
+ 'delegations.detachDelegate': function () {
302
+ return { __error: { id: 'delegation-genuine-login-required', message: 'direct login required' } };
303
+ }
304
+ }
305
+ });
306
+ const d = Delegation.fromConnection(conn);
307
+ let err = null;
308
+ try { await d.detachDelegate('bob'); } catch (e) { err = e; }
309
+ expect(err).to.be.instanceOf(DelegationError);
310
+ expect(err.id).to.equal(errorIds.GENUINE_LOGIN_REQUIRED);
311
+ });
312
+
313
+ it('[DELEB] getToken surfaces delegation-not-active with data payload', async function () {
314
+ const conn = makeStubConnection({
315
+ handlers: {
316
+ 'delegations.getToken': function () {
317
+ return { __error: { id: 'delegation-not-active', message: 'no longer active', data: { peerStatus: 403 } } };
318
+ }
319
+ }
320
+ });
321
+ const d = Delegation.fromConnection(conn);
322
+ let err = null;
323
+ try { await d.getToken('kid'); } catch (e) { err = e; }
324
+ expect(err).to.be.instanceOf(DelegationError);
325
+ expect(err.id).to.equal(errorIds.NOT_ACTIVE);
326
+ expect(err.data).to.deep.equal({ peerStatus: 403 });
327
+ });
328
+
329
+ it('[DELEC] requestAttach surfaces delegation-already-exists', async function () {
330
+ const conn = makeStubConnection({
331
+ handlers: {
332
+ 'delegations.requestAttach': function () {
333
+ return { __error: { id: 'delegation-already-exists', message: 'dup' } };
334
+ }
335
+ }
336
+ });
337
+ const d = Delegation.fromConnection(conn);
338
+ let err = null;
339
+ try { await d.requestAttach('bob'); } catch (e) { err = e; }
340
+ expect(err).to.be.instanceOf(DelegationError);
341
+ expect(err.id).to.equal(errorIds.ALREADY_EXISTS);
342
+ });
343
+
344
+ it('[DELED] createAccount surfaces delegation-username-taken + delegation-unknown-core', async function () {
345
+ for (const id of ['delegation-username-taken', 'delegation-unknown-core']) {
346
+ const conn = makeStubConnection({
347
+ handlers: { 'delegations.createAccount': function () { return { __error: { id, message: id } }; } }
348
+ });
349
+ const d = Delegation.fromConnection(conn);
350
+ let err = null;
351
+ try { await d.createAccount({ username: 'x' }); } catch (e) { err = e; }
352
+ expect(err).to.be.instanceOf(DelegationError);
353
+ expect(err.id).to.equal(id);
354
+ }
355
+ });
356
+
357
+ it('[DELEE] acceptAttach surfaces delegation-invite-expired', async function () {
358
+ const conn = makeStubConnection({
359
+ handlers: {
360
+ 'delegations.acceptAttach': function () {
361
+ return { __error: { id: 'delegation-invite-expired', message: 'gone' } };
362
+ }
363
+ }
364
+ });
365
+ const d = Delegation.fromConnection(conn);
366
+ let err = null;
367
+ try { await d.acceptAttach('kid'); } catch (e) { err = e; }
368
+ expect(err).to.be.instanceOf(DelegationError);
369
+ expect(err.id).to.equal(errorIds.INVITE_EXPIRED);
370
+ });
371
+
372
+ it('[DELEF] a non-delegation error passes through unchanged (not a DelegationError)', async function () {
373
+ const conn = makeStubConnection({
374
+ handlers: {
375
+ 'delegations.listDelegates': function () {
376
+ return { __error: { id: 'invalid-access-token', message: 'bad token' } };
377
+ }
378
+ }
379
+ });
380
+ const d = Delegation.fromConnection(conn);
381
+ let err = null;
382
+ try { await d.listDelegates(); } catch (e) { err = e; }
383
+ expect(err).to.not.be.instanceOf(DelegationError);
384
+ expect(err.innerObject.id).to.equal('invalid-access-token');
385
+ });
386
+ });