@calimero-network/mero-js 24.0.0 → 24.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -155,6 +155,54 @@ export interface CloudRelay {
155
155
  canExecute: boolean;
156
156
  lastSeenAt?: string | null;
157
157
  confirmedAt?: string | null;
158
+ /**
159
+ * How far along this relay's admission is, as the cloud judges it:
160
+ * `'confirmed'` (in), `'pending'` (trying), `'refused'` (alive and turned
161
+ * away), `'absent'` (stopped talking before getting in). `null` from a cloud
162
+ * that predates the field. A string, not a closed union, so a verdict the
163
+ * cloud adds later is passed through rather than dropped.
164
+ */
165
+ admission: string | null;
166
+ /** The cloud's one-line explanation of `admission`, for a person; `null` when none. */
167
+ admissionHint: string | null;
168
+ /**
169
+ * What the relay itself reported about its latest attempt to join, reasons
170
+ * included — or `null` when it has reported nothing (an older fleet node or
171
+ * cloud, or a relay assigned since its last report).
172
+ */
173
+ join: CloudRelayJoin | null;
174
+ }
175
+ /**
176
+ * A fleet node's own account of its latest attempt to join a namespace.
177
+ *
178
+ * A refusal happens peer to peer, between the node and the namespace's
179
+ * admitters, so this is the only place a client learns WHY a relay is stuck:
180
+ * the node forwards what each admitter it asked directly answered.
181
+ */
182
+ export interface CloudRelayJoin {
183
+ /**
184
+ * `'admitted'` (in, or an admitter said yes and the key is on its way),
185
+ * `'refused'` (every admitter asked declined; see `refusals`), `'waiting'`
186
+ * (nobody answered directly yet), or `'error'` (the attempt itself failed;
187
+ * see `error`). Passed through as sent.
188
+ */
189
+ state: string;
190
+ /** Consecutive attempts without admission, as the node counts them. */
191
+ attempts: number;
192
+ /**
193
+ * Each directly-asked admitter that declined: its libp2p peer id and its
194
+ * reason, verbatim. Text to show a person, never to parse — the most useful
195
+ * one is "was removed from group … and cannot rejoin; an admin must re-add
196
+ * them", which the namespace owner can fix by re-adding the node.
197
+ */
198
+ refusals: Array<{
199
+ peer: string;
200
+ reason: string;
201
+ }>;
202
+ /** Why the attempt itself failed, when `state` is `'error'`. */
203
+ error: string | null;
204
+ /** When the node reported this, ISO 8601 (UTC, no offset), or `null`. */
205
+ reportedAt: string | null;
158
206
  }
159
207
  /**
160
208
  * One node serving a namespace, as a caller with no cloud account sees it.
@@ -66,6 +66,27 @@ function teeRoleOf(row) {
66
66
  export function pickExecutingRelay(relays) {
67
67
  return (relays.find((r) => r.canExecute && r.relayUrl !== null && r.executorAccount !== null) ?? null);
68
68
  }
69
+ /** `join` from a relay row, or `null` when absent or not an object. */
70
+ function joinOf(row) {
71
+ const raw = row.join;
72
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
73
+ return null;
74
+ const join = raw;
75
+ if (typeof join.state !== 'string')
76
+ return null;
77
+ const refusals = Array.isArray(join.refusals)
78
+ ? join.refusals
79
+ .filter((r) => !!r && typeof r === 'object')
80
+ .map((r) => ({ peer: String(r.peer ?? ''), reason: String(r.reason ?? '') }))
81
+ : [];
82
+ return {
83
+ state: join.state,
84
+ attempts: typeof join.attempts === 'number' ? join.attempts : 0,
85
+ refusals,
86
+ error: typeof join.error === 'string' ? join.error : null,
87
+ reportedAt: typeof join.reported_at === 'string' ? join.reported_at : null,
88
+ };
89
+ }
69
90
  const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
70
91
  function accountHexOrNull(value) {
71
92
  return typeof value === 'string' && ACCOUNT_HEX.test(value) ? value : null;
@@ -349,6 +370,9 @@ export class CloudClient {
349
370
  canExecute: canExecuteOf(row, authorshipReady && relayUrl !== null && executorAccount !== null),
350
371
  lastSeenAt: row.last_seen_at ?? null,
351
372
  confirmedAt: row.confirmed_at ?? null,
373
+ admission: typeof row.admission === 'string' ? row.admission : null,
374
+ admissionHint: typeof row.admission_hint === 'string' ? row.admission_hint : null,
375
+ join: joinOf(row),
352
376
  };
353
377
  });
354
378
  }
@@ -1,5 +1,5 @@
1
1
  export { CloudClient, admitterAddrsFromNetworkStatus, AccountHaRefusedError, AccountNotLinkedError, AccountLinkedToSeveralUsersError, HaRequestPendingError, UnknownRelayError, RelayNotDialableError, } 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';
2
+ export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudRelayJoin, 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';
@@ -46,11 +46,12 @@ export interface DeviceCertInput {
46
46
  deviceEpoch: number;
47
47
  }
48
48
  /**
49
- * Mint a device id: `H(DEVICE_ID_DOMAIN, account ‖ nonce)`.
49
+ * Mint a device id: `nonce ‖ H(DEVICE_ID_DOMAIN, account ‖ nonce)[..16]`.
50
50
  *
51
51
  * Derived from the account and a fresh nonce rather than from the device's keys,
52
- * so rotating a keypair keeps the replica identity — and with it the counter
53
- * slots and HLC lineage — intact.
52
+ * so rotating a keypair keeps the replica identity - and with it the counter
53
+ * slots and HLC lineage - intact. The hash half binds the id to its account:
54
+ * core refuses a certificate naming an id minted for another account.
54
55
  */
55
56
  export declare function mintDeviceId(account: string, nonce: Uint8Array): Promise<string>;
56
57
  /**
@@ -34,18 +34,20 @@ export const ACCOUNT_GENESIS_VERSION = 2;
34
34
  /** Byte length of an `AccountProof<DeviceCert>` with an empty handoff chain. */
35
35
  const ACCOUNT_PROOF_BYTES = 237;
36
36
  /**
37
- * Mint a device id: `H(DEVICE_ID_DOMAIN, account ‖ nonce)`.
37
+ * Mint a device id: `nonce ‖ H(DEVICE_ID_DOMAIN, account ‖ nonce)[..16]`.
38
38
  *
39
39
  * Derived from the account and a fresh nonce rather than from the device's keys,
40
- * so rotating a keypair keeps the replica identity — and with it the counter
41
- * slots and HLC lineage — intact.
40
+ * so rotating a keypair keeps the replica identity - and with it the counter
41
+ * slots and HLC lineage - intact. The hash half binds the id to its account:
42
+ * core refuses a certificate naming an id minted for another account.
42
43
  */
43
44
  export async function mintDeviceId(account, nonce) {
44
45
  if (nonce.length !== 16) {
45
46
  throw new Error(`nonce must be 16 bytes, got ${nonce.length}`);
46
47
  }
47
48
  const accountBytes = fromHex(account, 'account', 32);
48
- return hex(await domainHash(DEVICE_ID_DOMAIN, [accountBytes, nonce]));
49
+ const binding = await domainHash(DEVICE_ID_DOMAIN, [accountBytes, nonce]);
50
+ return hex(concat(nonce, binding.subarray(0, 16)));
49
51
  }
50
52
  /**
51
53
  * The 32 bytes a root signs to certify a device.
@@ -1,4 +1,6 @@
1
1
  import type { GroupMembershipEventData, GroupMigrationEventData } from './group.js';
2
+ /** How long a subscription POST may take before it counts as failed. */
3
+ export declare const SUBSCRIPTION_TIMEOUT_MS = 10000;
2
4
  export type { GroupMembershipEventData, GroupMigrationEventData };
3
5
  export interface SseEventData {
4
6
  contextId: string;
@@ -109,6 +111,8 @@ export declare class SseClient {
109
111
  groupIds?: string[];
110
112
  }): Promise<void>;
111
113
  private sendSubscription;
114
+ /** Sends one subscription change and returns why it failed, or `null`. */
115
+ private postSubscription;
112
116
  private forceReconnect;
113
117
  private scheduleReconnect;
114
118
  close(): void;
@@ -17,6 +17,8 @@ const TERMINAL_AUTH_ERRORS = new Set(['token_reuse', 'token_revoked']);
17
17
  * `HTTPError(403)` and reconnected on a timer forever.
18
18
  */
19
19
  const TERMINAL_AUTH_STATUSES = new Set([401, 403]);
20
+ /** How long a subscription POST may take before it counts as failed. */
21
+ export const SUBSCRIPTION_TIMEOUT_MS = 10000;
20
22
  /**
21
23
  * Whether a failed response means the whole token family is gone, so the caller
22
24
  * must re-authenticate and the client must stop reconnecting.
@@ -233,18 +235,27 @@ export class SseClient {
233
235
  const msg = JSON.parse(jsonStr);
234
236
  // Connection message
235
237
  if (msg.type === 'connect' && msg.session_id) {
236
- this.sessionId = msg.session_id;
237
- this.emit('connect', msg.session_id);
238
+ const sessionId = msg.session_id;
239
+ this.sessionId = sessionId;
238
240
  // A new connection gets a new replay when it re-subscribes; what the
239
241
  // old one said may name authors who have since gone.
240
242
  this.presence.clear();
241
- // Re-subscribe after reconnect
242
- if (this.subscribedContextIds.size > 0 || this.subscribedGroupIds.size > 0) {
243
- this.sendSubscription('subscribe', {
244
- contextIds: [...this.subscribedContextIds],
245
- groupIds: [...this.subscribedGroupIds],
246
- });
243
+ if (this.subscribedContextIds.size === 0 && this.subscribedGroupIds.size === 0) {
244
+ this.emit('connect', sessionId);
245
+ return;
247
246
  }
247
+ // A new session starts subscribed to nothing, so announce it once the re-subscribe
248
+ // lands; a failed one still announces, then reports the error, as consumers saw before.
249
+ void this.postSubscription('subscribe', {
250
+ contextIds: [...this.subscribedContextIds],
251
+ groupIds: [...this.subscribedGroupIds],
252
+ }).then((failure) => {
253
+ if (this.closed || this.sessionId !== sessionId)
254
+ return;
255
+ this.emit('connect', sessionId);
256
+ if (failure)
257
+ this.emit('error', failure);
258
+ });
248
259
  return;
249
260
  }
250
261
  if (!msg.result)
@@ -348,6 +359,14 @@ export class SseClient {
348
359
  }
349
360
  }
350
361
  async sendSubscription(method, ids) {
362
+ const failure = await this.postSubscription(method, ids);
363
+ if (failure)
364
+ this.emit('error', failure);
365
+ }
366
+ /** Sends one subscription change and returns why it failed, or `null`. */
367
+ async postSubscription(method, ids) {
368
+ const timeout = new AbortController();
369
+ const timer = setTimeout(() => timeout.abort(new DOMException(`SSE ${method} timed out`, 'TimeoutError')), SUBSCRIPTION_TIMEOUT_MS);
351
370
  try {
352
371
  const params = {};
353
372
  if (ids.contextIds && ids.contextIds.length > 0)
@@ -367,6 +386,7 @@ export class SseClient {
367
386
  'Content-Type': 'application/json',
368
387
  },
369
388
  body,
389
+ signal: timeout.signal,
370
390
  });
371
391
  if (!response.ok) {
372
392
  // The subscribe path used to report only the status. On the one failure
@@ -377,13 +397,17 @@ export class SseClient {
377
397
  // does carry a body.
378
398
  const terminal = isTerminalAuthFailure(response);
379
399
  const bodyText = await response.text().catch(() => undefined);
380
- this.emit('error', terminal
400
+ return terminal
381
401
  ? new AuthRevokedError(terminal, response.status, response.statusText, `${this.baseUrl}/sse/subscription`, response.headers, bodyText)
382
- : new HTTPError(response.status, response.statusText, `${this.baseUrl}/sse/subscription`, response.headers, bodyText));
402
+ : new HTTPError(response.status, response.statusText, `${this.baseUrl}/sse/subscription`, response.headers, bodyText);
383
403
  }
404
+ return null;
384
405
  }
385
406
  catch (err) {
386
- this.emit('error', err instanceof Error ? err : new Error(`SSE ${method} failed`));
407
+ return err instanceof Error ? err : new Error(`SSE ${method} failed`);
408
+ }
409
+ finally {
410
+ clearTimeout(timer);
387
411
  }
388
412
  }
389
413
  forceReconnect() {