@calimero-network/mero-js 23.4.2 → 23.5.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/dist/cloud/account-ownership.d.ts +29 -4
- package/dist/cloud/account-ownership.js +34 -8
- package/dist/cloud/cloud-client.d.ts +68 -1
- package/dist/cloud/cloud-client.js +112 -0
- package/dist/cloud/index.d.ts +4 -4
- package/dist/cloud/index.js +2 -2
- package/dist/ephemeral/index.js +10 -0
- package/dist/events/sse.d.ts +17 -0
- package/dist/events/sse.js +44 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +179 -42
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +179 -42
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type Signer } from '../signer/signer.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The `ownership_proof` body for `POST /api/cloud/me/namespaces/{ns}/enable-ha`
|
|
4
|
+
* and for `POST /api/cloud/accounts/{account}/namespaces/{ns}/enable-ha`.
|
|
5
|
+
*/
|
|
3
6
|
export interface AccountOwnershipProof {
|
|
4
7
|
kind: 'account';
|
|
5
8
|
/** hex `AccountProof<DeviceCert>`, the account's credential. */
|
|
@@ -9,15 +12,14 @@ export interface AccountOwnershipProof {
|
|
|
9
12
|
/** base64 ed25519 by the certified device key. */
|
|
10
13
|
signature: string;
|
|
11
14
|
}
|
|
12
|
-
|
|
15
|
+
/** What every account claim needs; the audience and `subject` differ per route. */
|
|
16
|
+
interface AccountClaimBase {
|
|
13
17
|
/** The namespace, hex: the id `foundNamespace` returned. */
|
|
14
18
|
namespaceId: string;
|
|
15
19
|
/** The founding account, hex. */
|
|
16
20
|
accountId: string;
|
|
17
21
|
/** The salt `foundNamespace` returned, hex (32 bytes). */
|
|
18
22
|
salt: string;
|
|
19
|
-
/** The cloud login's email: the claim is for this login only. */
|
|
20
|
-
subject: string;
|
|
21
23
|
/** The account's credential, hex `AccountProof<DeviceCert>`. */
|
|
22
24
|
credential: string;
|
|
23
25
|
deviceSecret?: string;
|
|
@@ -27,4 +29,27 @@ export interface AccountOwnershipClaimInput {
|
|
|
27
29
|
/** For tests. */
|
|
28
30
|
now?: () => number;
|
|
29
31
|
}
|
|
32
|
+
export interface AccountOwnershipClaimInput extends AccountClaimBase {
|
|
33
|
+
/** The cloud login's email: the claim is for this login only. */
|
|
34
|
+
subject: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Input for {@link signAccountHaClaim}: the same as the session claim, minus
|
|
38
|
+
* `subject` — there is no login to bind it to.
|
|
39
|
+
*/
|
|
40
|
+
export type AccountHaClaimInput = AccountClaimBase;
|
|
41
|
+
/** The audience of the session route's claim (`/api/cloud/me/...`). */
|
|
42
|
+
export declare const ACCOUNT_OWNERSHIP_AUDIENCE = "mdma:enable-ha-namespace";
|
|
43
|
+
/** The audience of the anonymous account route's claim (`/api/cloud/accounts/...`). */
|
|
44
|
+
export declare const ACCOUNT_HA_AUDIENCE = "mdma:enable-ha-namespace-as-account";
|
|
45
|
+
/** The founder's claim for the SESSION route, bound to one cloud login (`subject`). */
|
|
30
46
|
export declare function signAccountOwnershipClaim(input: AccountOwnershipClaimInput): Promise<AccountOwnershipProof>;
|
|
47
|
+
/**
|
|
48
|
+
* The founder's claim for the ANONYMOUS account route
|
|
49
|
+
* (`POST /api/cloud/accounts/{account}/namespaces/{ns}/enable-ha`): no cloud
|
|
50
|
+
* session, so no `subject`, and its own audience so a claim minted for one
|
|
51
|
+
* route can never be replayed on the other. The cloud finds the user to bill
|
|
52
|
+
* through the account's wallet link instead.
|
|
53
|
+
*/
|
|
54
|
+
export declare function signAccountHaClaim(input: AccountHaClaimInput): Promise<AccountOwnershipProof>;
|
|
55
|
+
export {};
|
|
@@ -19,6 +19,10 @@ const DOMAIN = new TextEncoder().encode('calimero.mdma.account-ownership-claim.v
|
|
|
19
19
|
/** What the cloud accepts; it caps a claim's lifetime at five minutes. */
|
|
20
20
|
const DEFAULT_TTL_MS = 60000;
|
|
21
21
|
const MAX_TTL_MS = 5 * 60000;
|
|
22
|
+
/** The audience of the session route's claim (`/api/cloud/me/...`). */
|
|
23
|
+
export const ACCOUNT_OWNERSHIP_AUDIENCE = 'mdma:enable-ha-namespace';
|
|
24
|
+
/** The audience of the anonymous account route's claim (`/api/cloud/accounts/...`). */
|
|
25
|
+
export const ACCOUNT_HA_AUDIENCE = 'mdma:enable-ha-namespace-as-account';
|
|
22
26
|
function base64(bytes) {
|
|
23
27
|
let binary = '';
|
|
24
28
|
for (const b of bytes)
|
|
@@ -30,22 +34,28 @@ const hex32 = (value, what) => {
|
|
|
30
34
|
throw new Error(`${what} must be 64 hex characters`);
|
|
31
35
|
return value.toLowerCase();
|
|
32
36
|
};
|
|
33
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Sign one claim. `subject` is spliced in after `salt` only when given, so the
|
|
39
|
+
* session claim keeps its exact field order and the account claim has no
|
|
40
|
+
* `subject` key at all (not a null one: the cloud tells the two apart by it).
|
|
41
|
+
*/
|
|
42
|
+
async function signClaim(input, audience, subject) {
|
|
34
43
|
const ttl = input.ttlMs ?? DEFAULT_TTL_MS;
|
|
35
44
|
if (!(ttl > 0 && ttl <= MAX_TTL_MS))
|
|
36
45
|
throw new Error(`ttlMs must be in (0, ${MAX_TTL_MS}]`);
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const groupId = hex32(input.namespaceId, 'namespaceId');
|
|
47
|
+
const accountId = hex32(input.accountId, 'accountId');
|
|
48
|
+
const salt = hex32(input.salt, 'salt');
|
|
39
49
|
const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
40
50
|
const issued = (input.now ?? Date.now)();
|
|
41
51
|
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
42
52
|
const payload = new TextEncoder().encode(JSON.stringify({
|
|
43
53
|
v: 1,
|
|
44
|
-
audience
|
|
45
|
-
group_id:
|
|
46
|
-
account_id:
|
|
47
|
-
salt
|
|
48
|
-
subject:
|
|
54
|
+
audience,
|
|
55
|
+
group_id: groupId,
|
|
56
|
+
account_id: accountId,
|
|
57
|
+
salt,
|
|
58
|
+
...(subject === undefined ? {} : { subject }),
|
|
49
59
|
nonce,
|
|
50
60
|
issued_at_ms: issued,
|
|
51
61
|
expires_at_ms: issued + ttl,
|
|
@@ -53,3 +63,19 @@ export async function signAccountOwnershipClaim(input) {
|
|
|
53
63
|
const signature = await signer.sign(concat(DOMAIN, payload));
|
|
54
64
|
return { kind: 'account', credential: input.credential, signed_payload: base64(payload), signature: base64(signature) };
|
|
55
65
|
}
|
|
66
|
+
/** The founder's claim for the SESSION route, bound to one cloud login (`subject`). */
|
|
67
|
+
export async function signAccountOwnershipClaim(input) {
|
|
68
|
+
if (!input.subject)
|
|
69
|
+
throw new Error('subject must be the cloud login email');
|
|
70
|
+
return signClaim(input, ACCOUNT_OWNERSHIP_AUDIENCE, input.subject);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The founder's claim for the ANONYMOUS account route
|
|
74
|
+
* (`POST /api/cloud/accounts/{account}/namespaces/{ns}/enable-ha`): no cloud
|
|
75
|
+
* session, so no `subject`, and its own audience so a claim minted for one
|
|
76
|
+
* route can never be replayed on the other. The cloud finds the user to bill
|
|
77
|
+
* through the account's wallet link instead.
|
|
78
|
+
*/
|
|
79
|
+
export async function signAccountHaClaim(input) {
|
|
80
|
+
return signClaim(input, ACCOUNT_HA_AUDIENCE, undefined);
|
|
81
|
+
}
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* ever sent, and would be a throughput bottleneck on a path that has no reason
|
|
25
25
|
* to involve it.
|
|
26
26
|
*/
|
|
27
|
-
import type
|
|
27
|
+
import { type AccountOwnershipProof } from './account-ownership.js';
|
|
28
28
|
import { type RootSource } from '../account/index.js';
|
|
29
|
+
import { HTTPError } from '../http-client/web-client.js';
|
|
29
30
|
import type { Signer } from '../signer/signer.js';
|
|
30
31
|
import { type DiscoveryChallenge, type RoutingChallenge, type RoutingCredential } from './routing-proof.js';
|
|
31
32
|
/**
|
|
@@ -365,6 +366,54 @@ export interface DisableHAOptions {
|
|
|
365
366
|
contextId: string;
|
|
366
367
|
redirectUrl?: string;
|
|
367
368
|
}
|
|
369
|
+
/** Options for {@link CloudClient.enableHaAsAccount}. */
|
|
370
|
+
export interface EnableHaAsAccountOptions {
|
|
371
|
+
/** The namespace, hex: the id `foundNamespace` returned. */
|
|
372
|
+
namespaceId: string;
|
|
373
|
+
/** The salt `foundNamespace` returned, hex (32 bytes). */
|
|
374
|
+
salt: string;
|
|
375
|
+
/** The founding account, hex. */
|
|
376
|
+
accountId: string;
|
|
377
|
+
/** The account's credential, hex `AccountProof<DeviceCert>`. */
|
|
378
|
+
credential: string;
|
|
379
|
+
/** The certified device's secret, hex. Give this or `signer`. */
|
|
380
|
+
deviceSecret?: string;
|
|
381
|
+
signer?: Signer;
|
|
382
|
+
/** The claim's lifetime in ms; default one minute, at most five. */
|
|
383
|
+
ttlMs?: number;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The cloud's two refusals of {@link CloudClient.enableHaAsAccount} that a
|
|
387
|
+
* person can do something about. Both are `409`s with `{"error": code}`.
|
|
388
|
+
*
|
|
389
|
+
* Extends {@link HTTPError}, so `instanceof HTTPError` handling still catches it.
|
|
390
|
+
*/
|
|
391
|
+
export declare class AccountHaRefusedError extends HTTPError {
|
|
392
|
+
/** The cloud's `error` code, verbatim. */
|
|
393
|
+
readonly code: string;
|
|
394
|
+
name: string;
|
|
395
|
+
constructor(
|
|
396
|
+
/** The cloud's `error` code, verbatim. */
|
|
397
|
+
code: string, status: number, statusText: string, url: string, headers: Headers, bodyText?: string);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* `account_not_linked`: the account was never linked to a cloud user through
|
|
401
|
+
* the wallet, so the cloud has nobody to host the namespace for. Linking it
|
|
402
|
+
* fixes this; retrying does not.
|
|
403
|
+
*/
|
|
404
|
+
export declare class AccountNotLinkedError extends AccountHaRefusedError {
|
|
405
|
+
name: string;
|
|
406
|
+
static readonly CODE = "account_not_linked";
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* `account_linked_to_several_users`: more than one cloud user linked this
|
|
410
|
+
* account, so the cloud cannot tell whose quota hosts the namespace. Unlink it
|
|
411
|
+
* from all but one.
|
|
412
|
+
*/
|
|
413
|
+
export declare class AccountLinkedToSeveralUsersError extends AccountHaRefusedError {
|
|
414
|
+
name: string;
|
|
415
|
+
static readonly CODE = "account_linked_to_several_users";
|
|
416
|
+
}
|
|
368
417
|
export declare class CloudClient {
|
|
369
418
|
private baseUrl;
|
|
370
419
|
private session;
|
|
@@ -610,6 +659,24 @@ export declare class CloudClient {
|
|
|
610
659
|
* The login must be linked to that account.
|
|
611
660
|
*/
|
|
612
661
|
enableFoundedNamespaceHa(namespaceId: string, ownershipProof: AccountOwnershipProof): Promise<unknown>;
|
|
662
|
+
/**
|
|
663
|
+
* Enable HA for a namespace this account founded, with **no cloud session**.
|
|
664
|
+
*
|
|
665
|
+
* The anonymous sibling of {@link enableFoundedNamespaceHa}: it signs the
|
|
666
|
+
* founder's claim itself ({@link signAccountHaClaim} — no `subject`, its own
|
|
667
|
+
* audience) and posts it to
|
|
668
|
+
* `/api/cloud/accounts/{accountId}/namespaces/{namespaceId}/enable-ha`. The
|
|
669
|
+
* cloud bills the user the account is linked to through the wallet, so the
|
|
670
|
+
* account must be linked to exactly one:
|
|
671
|
+
*
|
|
672
|
+
* - not linked at all → {@link AccountNotLinkedError};
|
|
673
|
+
* - linked to several → {@link AccountLinkedToSeveralUsersError}.
|
|
674
|
+
*
|
|
675
|
+
* A claim that does not verify is a `403` `HTTPError`; quota and the rest
|
|
676
|
+
* answer as the session route does. Returns the cloud's response body, the
|
|
677
|
+
* same shape the session route's enable-ha returns.
|
|
678
|
+
*/
|
|
679
|
+
enableHaAsAccount(options: EnableHaAsAccountOptions): Promise<unknown>;
|
|
613
680
|
/**
|
|
614
681
|
* Stop hosting a claimed namespace.
|
|
615
682
|
*
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* ever sent, and would be a throughput bottleneck on a path that has no reason
|
|
25
25
|
* to involve it.
|
|
26
26
|
*/
|
|
27
|
+
import { signAccountHaClaim } from './account-ownership.js';
|
|
27
28
|
import { resolveRoot, resolveRootPair, signAccountLink, signAccountLogin, } from '../account/index.js';
|
|
28
29
|
import { HTTPError } from '../http-client/web-client.js';
|
|
29
30
|
import { routingProofHeaders, } from './routing-proof.js';
|
|
@@ -65,6 +66,68 @@ function teeRoleOf(row) {
|
|
|
65
66
|
export function pickExecutingRelay(relays) {
|
|
66
67
|
return (relays.find((r) => r.canExecute && r.relayUrl !== null && r.executorAccount !== null) ?? null);
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The cloud's two refusals of {@link CloudClient.enableHaAsAccount} that a
|
|
71
|
+
* person can do something about. Both are `409`s with `{"error": code}`.
|
|
72
|
+
*
|
|
73
|
+
* Extends {@link HTTPError}, so `instanceof HTTPError` handling still catches it.
|
|
74
|
+
*/
|
|
75
|
+
export class AccountHaRefusedError extends HTTPError {
|
|
76
|
+
constructor(
|
|
77
|
+
/** The cloud's `error` code, verbatim. */
|
|
78
|
+
code, status, statusText, url, headers, bodyText) {
|
|
79
|
+
super(status, statusText, url, headers, bodyText);
|
|
80
|
+
this.code = code;
|
|
81
|
+
this.name = 'AccountHaRefusedError';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `account_not_linked`: the account was never linked to a cloud user through
|
|
86
|
+
* the wallet, so the cloud has nobody to host the namespace for. Linking it
|
|
87
|
+
* fixes this; retrying does not.
|
|
88
|
+
*/
|
|
89
|
+
export class AccountNotLinkedError extends AccountHaRefusedError {
|
|
90
|
+
constructor() {
|
|
91
|
+
super(...arguments);
|
|
92
|
+
this.name = 'AccountNotLinkedError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
AccountNotLinkedError.CODE = 'account_not_linked';
|
|
96
|
+
/**
|
|
97
|
+
* `account_linked_to_several_users`: more than one cloud user linked this
|
|
98
|
+
* account, so the cloud cannot tell whose quota hosts the namespace. Unlink it
|
|
99
|
+
* from all but one.
|
|
100
|
+
*/
|
|
101
|
+
export class AccountLinkedToSeveralUsersError extends AccountHaRefusedError {
|
|
102
|
+
constructor() {
|
|
103
|
+
super(...arguments);
|
|
104
|
+
this.name = 'AccountLinkedToSeveralUsersError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
AccountLinkedToSeveralUsersError.CODE = 'account_linked_to_several_users';
|
|
108
|
+
/**
|
|
109
|
+
* The `error` code of a 409 body. Accepts `{"error": code}` (the contract) and
|
|
110
|
+
* FastAPI's `{"detail": code}` / `{"detail": {"error": code}}`, so how the
|
|
111
|
+
* route raises it does not change what a caller catches.
|
|
112
|
+
*/
|
|
113
|
+
function conflictCode(bodyText) {
|
|
114
|
+
if (!bodyText)
|
|
115
|
+
return undefined;
|
|
116
|
+
try {
|
|
117
|
+
const body = JSON.parse(bodyText);
|
|
118
|
+
if (typeof body?.error === 'string')
|
|
119
|
+
return body.error;
|
|
120
|
+
if (typeof body?.detail === 'string')
|
|
121
|
+
return body.detail;
|
|
122
|
+
const nested = body?.detail?.error;
|
|
123
|
+
if (typeof nested === 'string')
|
|
124
|
+
return nested;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Not JSON: not one of ours.
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
68
131
|
/** Response headers MDMA uses to hand back a rolling-refreshed session. */
|
|
69
132
|
const REFRESH_HEADER = 'x-mdma-session-refresh';
|
|
70
133
|
const REFRESH_EXPIRES_HEADER = 'x-mdma-session-expires';
|
|
@@ -509,6 +572,55 @@ export class CloudClient {
|
|
|
509
572
|
async enableFoundedNamespaceHa(namespaceId, ownershipProof) {
|
|
510
573
|
return this.request('POST', `/api/cloud/me/namespaces/${encodeURIComponent(namespaceId)}/enable-ha`, { body: { groups: [], ownership_proof: ownershipProof } });
|
|
511
574
|
}
|
|
575
|
+
/**
|
|
576
|
+
* Enable HA for a namespace this account founded, with **no cloud session**.
|
|
577
|
+
*
|
|
578
|
+
* The anonymous sibling of {@link enableFoundedNamespaceHa}: it signs the
|
|
579
|
+
* founder's claim itself ({@link signAccountHaClaim} — no `subject`, its own
|
|
580
|
+
* audience) and posts it to
|
|
581
|
+
* `/api/cloud/accounts/{accountId}/namespaces/{namespaceId}/enable-ha`. The
|
|
582
|
+
* cloud bills the user the account is linked to through the wallet, so the
|
|
583
|
+
* account must be linked to exactly one:
|
|
584
|
+
*
|
|
585
|
+
* - not linked at all → {@link AccountNotLinkedError};
|
|
586
|
+
* - linked to several → {@link AccountLinkedToSeveralUsersError}.
|
|
587
|
+
*
|
|
588
|
+
* A claim that does not verify is a `403` `HTTPError`; quota and the rest
|
|
589
|
+
* answer as the session route does. Returns the cloud's response body, the
|
|
590
|
+
* same shape the session route's enable-ha returns.
|
|
591
|
+
*/
|
|
592
|
+
async enableHaAsAccount(options) {
|
|
593
|
+
const proof = await signAccountHaClaim({
|
|
594
|
+
namespaceId: options.namespaceId,
|
|
595
|
+
accountId: options.accountId,
|
|
596
|
+
salt: options.salt,
|
|
597
|
+
credential: options.credential,
|
|
598
|
+
deviceSecret: options.deviceSecret,
|
|
599
|
+
signer: options.signer,
|
|
600
|
+
ttlMs: options.ttlMs,
|
|
601
|
+
});
|
|
602
|
+
// The path carries the ids as the claim does — lowercase hex — so a caller
|
|
603
|
+
// that kept them uppercase does not address a different resource.
|
|
604
|
+
const account = options.accountId.toLowerCase();
|
|
605
|
+
const namespace = options.namespaceId.toLowerCase();
|
|
606
|
+
try {
|
|
607
|
+
return await this.request('POST', `/api/cloud/accounts/${encodeURIComponent(account)}/namespaces/${encodeURIComponent(namespace)}/enable-ha`, { body: { ownership_proof: proof }, anonymous: true });
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
if (err instanceof HTTPError && err.status === 409) {
|
|
611
|
+
const code = conflictCode(err.bodyText);
|
|
612
|
+
const Typed = code === AccountNotLinkedError.CODE
|
|
613
|
+
? AccountNotLinkedError
|
|
614
|
+
: code === AccountLinkedToSeveralUsersError.CODE
|
|
615
|
+
? AccountLinkedToSeveralUsersError
|
|
616
|
+
: undefined;
|
|
617
|
+
if (Typed && code) {
|
|
618
|
+
throw new Typed(code, err.status, err.statusText, err.url, err.headers, err.bodyText);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
throw err;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
512
624
|
/**
|
|
513
625
|
* Stop hosting a claimed namespace.
|
|
514
626
|
*
|
package/dist/cloud/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { CloudClient, admitterAddrsFromNetworkStatus } from './cloud-client.js';
|
|
2
|
-
export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudAccountRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, AccountLinkHandoff, AccountLinkCallback, AccountLoginChallenge, AccountLoginProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
|
|
1
|
+
export { CloudClient, admitterAddrsFromNetworkStatus, AccountHaRefusedError, AccountNotLinkedError, AccountLinkedToSeveralUsersError, } from './cloud-client.js';
|
|
2
|
+
export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudAccountRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, AccountLinkHandoff, AccountLinkCallback, AccountLoginChallenge, AccountLoginProof, EnableHAOptions, DisableHAOptions, EnableHaAsAccountOptions, } from './cloud-client.js';
|
|
3
3
|
export { connectCloud, connectCloudWithAccount } from './connect.js';
|
|
4
4
|
export type { ConnectCloudOptions, ConnectCloudWithAccountOptions, CloudConnection, } from './connect.js';
|
|
5
5
|
export { signRoutingChallenge, routingProofHeaders } from './routing-proof.js';
|
|
@@ -8,5 +8,5 @@ export { cloudLinkUrl, beginCloudLink, readCloudLinkCallback, completeCloudLink,
|
|
|
8
8
|
export type { BeginCloudLinkOptions, CloudLinkCallback, CompleteCloudLinkOptions, } from './link-redirect.js';
|
|
9
9
|
export { deviceEnrolmentUrl, beginDeviceEnrolment, readEnrolmentCallback, completeDeviceEnrolment, callbackIsForeign, } from './enrol-redirect.js';
|
|
10
10
|
export type { DeviceEnrolmentOptions, DeviceEnrolmentCallback, CompleteDeviceEnrolmentOptions, EnrolledDevice, } from './enrol-redirect.js';
|
|
11
|
-
export { signAccountOwnershipClaim } from './account-ownership.js';
|
|
12
|
-
export type { AccountOwnershipProof, AccountOwnershipClaimInput } from './account-ownership.js';
|
|
11
|
+
export { signAccountOwnershipClaim, signAccountHaClaim, ACCOUNT_OWNERSHIP_AUDIENCE, ACCOUNT_HA_AUDIENCE, } from './account-ownership.js';
|
|
12
|
+
export type { AccountOwnershipProof, AccountOwnershipClaimInput, AccountHaClaimInput, } from './account-ownership.js';
|
package/dist/cloud/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { CloudClient, admitterAddrsFromNetworkStatus } from './cloud-client.js';
|
|
1
|
+
export { CloudClient, admitterAddrsFromNetworkStatus, AccountHaRefusedError, AccountNotLinkedError, AccountLinkedToSeveralUsersError, } from './cloud-client.js';
|
|
2
2
|
export { connectCloud, connectCloudWithAccount } from './connect.js';
|
|
3
3
|
export { signRoutingChallenge, routingProofHeaders } from './routing-proof.js';
|
|
4
4
|
// Linking an account to a cloud login from a browser — the redirect half the
|
|
@@ -7,4 +7,4 @@ export { cloudLinkUrl, beginCloudLink, readCloudLinkCallback, completeCloudLink,
|
|
|
7
7
|
// Getting this app's device key certified by a wallet on another origin — the
|
|
8
8
|
// redirect that keeps the account root out of the app's reach.
|
|
9
9
|
export { deviceEnrolmentUrl, beginDeviceEnrolment, readEnrolmentCallback, completeDeviceEnrolment, callbackIsForeign, } from './enrol-redirect.js';
|
|
10
|
-
export { signAccountOwnershipClaim } from './account-ownership.js';
|
|
10
|
+
export { signAccountOwnershipClaim, signAccountHaClaim, ACCOUNT_OWNERSHIP_AUDIENCE, ACCOUNT_HA_AUDIENCE, } from './account-ownership.js';
|
package/dist/ephemeral/index.js
CHANGED
|
@@ -81,6 +81,16 @@ export function subscribePresence(sse, contextId, handler, codec = jsonCodec())
|
|
|
81
81
|
handler(entry);
|
|
82
82
|
};
|
|
83
83
|
sse.on('event', listener);
|
|
84
|
+
// On a context this connection already holds, the node's one-time replay
|
|
85
|
+
// went to whichever listener came first: seed this one from what the
|
|
86
|
+
// client has kept, as the replay would have. (A context not held yet is
|
|
87
|
+
// replayed by the node on the subscribe below.) Optional: a test double
|
|
88
|
+
// or another event source may not keep presence.
|
|
89
|
+
if (sse.isSubscribed?.(contextId)) {
|
|
90
|
+
for (const data of sse.presenceSeed?.(contextId) ?? []) {
|
|
91
|
+
listener({ contextId, type: 'Ephemeral', data });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
84
94
|
// Errors surface via the SSE client's own 'error' event; nothing more to
|
|
85
95
|
// do with them here.
|
|
86
96
|
void sse.connect().catch(() => undefined);
|
package/dist/events/sse.d.ts
CHANGED
|
@@ -41,6 +41,14 @@ export declare class SseClient {
|
|
|
41
41
|
private reconnectTimer;
|
|
42
42
|
private subscribedContextIds;
|
|
43
43
|
private subscribedGroupIds;
|
|
44
|
+
/**
|
|
45
|
+
* The current presence of each subscribed context, as the node's replay and
|
|
46
|
+
* its live deltas left it: author → that author's last `Ephemeral` data and
|
|
47
|
+
* when it arrived. The node replays a context's presence once, when the
|
|
48
|
+
* context is first subscribed on this connection, so a presence listener
|
|
49
|
+
* that comes later would never see it; {@link presenceSeed} gives it this.
|
|
50
|
+
*/
|
|
51
|
+
private presence;
|
|
44
52
|
private closed;
|
|
45
53
|
private listeners;
|
|
46
54
|
private fetchImpl;
|
|
@@ -87,6 +95,15 @@ export declare class SseClient {
|
|
|
87
95
|
contextIds?: string[];
|
|
88
96
|
groupIds?: string[];
|
|
89
97
|
}): Promise<void>;
|
|
98
|
+
/** Whether this connection holds `contextId`'s events. */
|
|
99
|
+
isSubscribed(contextId: string): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The presence currently known for `contextId`, as `Ephemeral` event data,
|
|
102
|
+
* each entry's `ageMs` advanced by how long ago it arrived. Empty for a
|
|
103
|
+
* context this connection does not hold.
|
|
104
|
+
*/
|
|
105
|
+
presenceSeed(contextId: string): Array<Record<string, unknown>>;
|
|
106
|
+
private notePresence;
|
|
90
107
|
unsubscribe(idsOrOpts: string[] | {
|
|
91
108
|
contextIds?: string[];
|
|
92
109
|
groupIds?: string[];
|
package/dist/events/sse.js
CHANGED
|
@@ -39,6 +39,14 @@ export class SseClient {
|
|
|
39
39
|
this.reconnectTimer = null;
|
|
40
40
|
this.subscribedContextIds = new Set();
|
|
41
41
|
this.subscribedGroupIds = new Set();
|
|
42
|
+
/**
|
|
43
|
+
* The current presence of each subscribed context, as the node's replay and
|
|
44
|
+
* its live deltas left it: author → that author's last `Ephemeral` data and
|
|
45
|
+
* when it arrived. The node replays a context's presence once, when the
|
|
46
|
+
* context is first subscribed on this connection, so a presence listener
|
|
47
|
+
* that comes later would never see it; {@link presenceSeed} gives it this.
|
|
48
|
+
*/
|
|
49
|
+
this.presence = new Map();
|
|
42
50
|
this.closed = false;
|
|
43
51
|
this.listeners = { connect: [], event: [], error: [] };
|
|
44
52
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
@@ -227,6 +235,9 @@ export class SseClient {
|
|
|
227
235
|
if (msg.type === 'connect' && msg.session_id) {
|
|
228
236
|
this.sessionId = msg.session_id;
|
|
229
237
|
this.emit('connect', msg.session_id);
|
|
238
|
+
// A new connection gets a new replay when it re-subscribes; what the
|
|
239
|
+
// old one said may name authors who have since gone.
|
|
240
|
+
this.presence.clear();
|
|
230
241
|
// Re-subscribe after reconnect
|
|
231
242
|
if (this.subscribedContextIds.size > 0 || this.subscribedGroupIds.size > 0) {
|
|
232
243
|
this.sendSubscription('subscribe', {
|
|
@@ -252,6 +263,8 @@ export class SseClient {
|
|
|
252
263
|
}
|
|
253
264
|
// Context event message
|
|
254
265
|
if (msg.result.contextId) {
|
|
266
|
+
if (msg.result.type === 'Ephemeral')
|
|
267
|
+
this.notePresence(msg.result.contextId, eventData);
|
|
255
268
|
this.emit('event', {
|
|
256
269
|
contextId: msg.result.contextId,
|
|
257
270
|
// Forward the event tag (sibling of `data` in core's flattened
|
|
@@ -289,12 +302,43 @@ export class SseClient {
|
|
|
289
302
|
await this.sendSubscription('subscribe', { contextIds: newContextIds, groupIds: newGroupIds });
|
|
290
303
|
}
|
|
291
304
|
}
|
|
305
|
+
/** Whether this connection holds `contextId`'s events. */
|
|
306
|
+
isSubscribed(contextId) {
|
|
307
|
+
return this.subscribedContextIds.has(contextId);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The presence currently known for `contextId`, as `Ephemeral` event data,
|
|
311
|
+
* each entry's `ageMs` advanced by how long ago it arrived. Empty for a
|
|
312
|
+
* context this connection does not hold.
|
|
313
|
+
*/
|
|
314
|
+
presenceSeed(contextId) {
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
return [...(this.presence.get(contextId)?.values() ?? [])].map(({ data, at }) => ({
|
|
317
|
+
...data,
|
|
318
|
+
ageMs: (typeof data.ageMs === 'number' ? data.ageMs : 0) + (now - at),
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
notePresence(contextId, data) {
|
|
322
|
+
if (!this.subscribedContextIds.has(contextId))
|
|
323
|
+
return;
|
|
324
|
+
const d = data;
|
|
325
|
+
if (!d || typeof d.author !== 'string')
|
|
326
|
+
return;
|
|
327
|
+
let byAuthor = this.presence.get(contextId);
|
|
328
|
+
if (!byAuthor)
|
|
329
|
+
this.presence.set(contextId, (byAuthor = new Map()));
|
|
330
|
+
if (d.removed)
|
|
331
|
+
byAuthor.delete(d.author);
|
|
332
|
+
else
|
|
333
|
+
byAuthor.set(d.author, { data: d, at: Date.now() });
|
|
334
|
+
}
|
|
292
335
|
async unsubscribe(idsOrOpts) {
|
|
293
336
|
const opts = Array.isArray(idsOrOpts) ? { contextIds: idsOrOpts } : idsOrOpts;
|
|
294
337
|
const hadContextIds = (opts.contextIds ?? []).filter(id => this.subscribedContextIds.has(id));
|
|
295
338
|
const hadGroupIds = (opts.groupIds ?? []).filter(id => this.subscribedGroupIds.has(id));
|
|
296
339
|
for (const id of opts.contextIds ?? []) {
|
|
297
340
|
this.subscribedContextIds.delete(id);
|
|
341
|
+
this.presence.delete(id);
|
|
298
342
|
}
|
|
299
343
|
for (const id of opts.groupIds ?? []) {
|
|
300
344
|
this.subscribedGroupIds.delete(id);
|