@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.
@@ -1,5 +1,8 @@
1
1
  import { type Signer } from '../signer/signer.js';
2
- /** The `ownership_proof` body for `POST /api/cloud/me/namespaces/{ns}/enable-ha`. */
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
- export interface AccountOwnershipClaimInput {
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
- export async function signAccountOwnershipClaim(input) {
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
- if (!input.subject)
38
- throw new Error('subject must be the cloud login email');
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: 'mdma:enable-ha-namespace',
45
- group_id: hex32(input.namespaceId, 'namespaceId'),
46
- account_id: hex32(input.accountId, 'accountId'),
47
- salt: hex32(input.salt, 'salt'),
48
- subject: input.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 { AccountOwnershipProof } from './account-ownership.js';
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
  *
@@ -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';
@@ -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';
@@ -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);
@@ -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[];
@@ -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);