@calimero-network/mero-js 24.1.0 → 24.1.2

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.
@@ -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() {