@calimero-network/mero-js 23.2.0 → 23.3.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/index.d.ts CHANGED
@@ -15,7 +15,9 @@ export { MeroClient, createMeroClient, RelayTransport, RelayObserver, defaultAud
15
15
  export type { MeroClientConfig, NodeTransportConfig, RelayTransportConfig, RelayObserveConfig, ExecuteTransport, ExecuteResult, TransportKind, } from './transport/index.js';
16
16
  export { SseClient, WsClient } from "./events/index.js";
17
17
  export type { SseEventData, WsEventData, AppVersionChangedEvent, GroupMembershipEventData, GroupMigrationEventData, MigrationStartedData, MigrationProgressData, CascadeProgressData, MigrationCompletedData, } from "./events/index.js";
18
- export { EphemeralClient, jsonCodec } from "./ephemeral/index.js";
18
+ export { EphemeralClient, jsonCodec, subscribePresence } from "./ephemeral/index.js";
19
+ export { RelayPresenceClient } from "./presence/relay-presence.js";
20
+ export { presenceStatementBytes, stateHash, PRESENCE_DOMAIN } from "./presence/statement.js";
19
21
  export type { Codec, EphemeralEntry } from "./ephemeral/index.js";
20
22
  export * from "./cloud/index.js";
21
23
  export * from "./sealed/index.js";
package/dist/index.js CHANGED
@@ -24,7 +24,9 @@ RelayObserver, defaultAudience, } from './transport/index.js';
24
24
  // Events (SSE / WebSocket)
25
25
  export { SseClient, WsClient } from "./events/index.js";
26
26
  // Ephemeral presence (cursors / typing / online)
27
- export { EphemeralClient, jsonCodec } from "./ephemeral/index.js";
27
+ export { EphemeralClient, jsonCodec, subscribePresence } from "./ephemeral/index.js";
28
+ export { RelayPresenceClient } from "./presence/relay-presence.js";
29
+ export { presenceStatementBytes, stateHash, PRESENCE_DOMAIN } from "./presence/statement.js";
28
30
  // Cloud client — namespaces, relays, HA, and the cloud sign-in path
29
31
  export * from "./cloud/index.js";
30
32
  // Sealed transport — requests encrypted to a TEE node's attested key
package/dist/index.mjs CHANGED
@@ -3292,28 +3292,32 @@ var EphemeralClient = class {
3292
3292
  * and zero mean different things). Returns an unsubscribe function.
3293
3293
  */
3294
3294
  subscribe(contextId, handler, codec = jsonCodec()) {
3295
- const listener = (event) => {
3296
- const e = event;
3297
- if (e.type !== "Ephemeral" || e.contextId !== contextId) return;
3298
- const data = e.data;
3299
- if (!data?.author) return;
3300
- const removed = Boolean(data.removed);
3301
- const entry = {
3302
- author: data.author,
3303
- state: removed || data.state === void 0 ? void 0 : codec.decode(data.state),
3304
- removed
3305
- };
3306
- if (data.ageMs !== void 0) entry.ageMs = data.ageMs;
3307
- handler(entry);
3308
- };
3309
- this.sse.on("event", listener);
3310
- void this.sse.connect().catch(() => void 0);
3311
- void this.sse.subscribe([contextId]).catch(() => void 0);
3312
- return () => {
3313
- this.sse.off("event", listener);
3314
- };
3295
+ return subscribePresence(this.sse, contextId, handler, codec);
3315
3296
  }
3316
3297
  };
3298
+ function subscribePresence(sse, contextId, handler, codec = jsonCodec()) {
3299
+ const listener = (event) => {
3300
+ const e = event;
3301
+ if (e.type !== "Ephemeral" || e.contextId !== contextId) return;
3302
+ const data = e.data;
3303
+ if (!data?.author) return;
3304
+ const removed = Boolean(data.removed);
3305
+ const entry = {
3306
+ author: data.author,
3307
+ state: removed || data.state === void 0 ? void 0 : codec.decode(data.state),
3308
+ removed
3309
+ };
3310
+ if (data.ageMs !== void 0) entry.ageMs = data.ageMs;
3311
+ if (data.account !== void 0) entry.account = data.account;
3312
+ handler(entry);
3313
+ };
3314
+ sse.on("event", listener);
3315
+ void sse.connect().catch(() => void 0);
3316
+ void sse.subscribe([contextId]).catch(() => void 0);
3317
+ return () => {
3318
+ sse.off("event", listener);
3319
+ };
3320
+ }
3317
3321
 
3318
3322
  // src/signer/signer.ts
3319
3323
  var PKCS8_ED25519_PREFIX2 = new Uint8Array([
@@ -7739,6 +7743,19 @@ var RelayClient = class {
7739
7743
  );
7740
7744
  return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
7741
7745
  }
7746
+ /**
7747
+ * Publish one presence statement through the relay (`presence-intents`).
7748
+ *
7749
+ * Not retried on 429: presence resends every 2.5 s anyway, and a retry would
7750
+ * only queue behind the next resend. A refusal throws with its `status`.
7751
+ */
7752
+ async presenceIntent(contextId, body) {
7753
+ await this.jsonOnce(
7754
+ "POST",
7755
+ `/admin-api/contexts/${encodeURIComponent(contextId)}/presence-intents`,
7756
+ body
7757
+ );
7758
+ }
7742
7759
  /**
7743
7760
  * Ask the relay what it can do about creating contexts in `groupId`.
7744
7761
  *
@@ -8040,6 +8057,7 @@ var RelayClient = class {
8040
8057
  text
8041
8058
  );
8042
8059
  }
8060
+ if (response.status === 204) return void 0;
8043
8061
  return await response.json();
8044
8062
  }
8045
8063
  };
@@ -8467,6 +8485,121 @@ function signedPath(url) {
8467
8485
  return new URL(url).pathname;
8468
8486
  }
8469
8487
 
8488
+ // src/presence/statement.ts
8489
+ var PRESENCE_DOMAIN = new TextEncoder().encode("calimero/presence/1");
8490
+ async function stateHash(state) {
8491
+ const borsh = state === null ? new Uint8Array([0]) : concat(new Uint8Array([1]), u32le(state.length), state);
8492
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", borsh));
8493
+ }
8494
+ async function presenceStatementBytes(input) {
8495
+ return concat(
8496
+ PRESENCE_DOMAIN,
8497
+ fromHex(input.contextId, "contextId", 32),
8498
+ fromHex(input.author, "author", 32),
8499
+ u64le(input.seq),
8500
+ u64le(input.sentAtMs),
8501
+ await stateHash(input.state)
8502
+ );
8503
+ }
8504
+
8505
+ // src/presence/relay-presence.ts
8506
+ var HEARTBEAT_MS = 2500;
8507
+ var RelayPresenceClient = class {
8508
+ /**
8509
+ * @param opts.events the relay's event stream, asked for only on `subscribe`:
8510
+ * publishing needs none, so a client with no node key can still publish.
8511
+ */
8512
+ constructor(opts) {
8513
+ this.timers = /* @__PURE__ */ new Map();
8514
+ this.lastSeq = /* @__PURE__ */ new Map();
8515
+ this.refused = /* @__PURE__ */ new Set();
8516
+ /**
8517
+ * Bumped by every `set` and `close`, per context. A `set` installs its
8518
+ * resends only if nothing newer happened while its first send was in flight,
8519
+ * so a retract (or a newer state) can never be overtaken by an older one.
8520
+ */
8521
+ this.generation = /* @__PURE__ */ new Map();
8522
+ this.relay = opts.relay;
8523
+ this.events = opts.events;
8524
+ this.heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
8525
+ this.now = opts.now ?? Date.now;
8526
+ }
8527
+ /**
8528
+ * Set this account's presence in `contextId`, or retract it with `null`.
8529
+ * Throws when the relay refuses this first send for good (a 4xx other than
8530
+ * 429), which also stops the resends. A rate limit, a network failure or a
8531
+ * 5xx is not thrown: the next resend covers it.
8532
+ */
8533
+ async set(contextId, state, codec = jsonCodec()) {
8534
+ const generation = this.bump(contextId);
8535
+ this.refused.delete(contextId);
8536
+ const bytes = state === null ? null : new Uint8Array(codec.encode(state));
8537
+ await this.send(contextId, bytes);
8538
+ if (bytes === null || this.refused.has(contextId)) return;
8539
+ if (this.generation.get(contextId) !== generation) return;
8540
+ this.timers.set(
8541
+ contextId,
8542
+ setInterval(() => void this.send(contextId, bytes).catch(() => void 0), this.heartbeatMs)
8543
+ );
8544
+ }
8545
+ subscribe(contextId, handler, codec = jsonCodec()) {
8546
+ return subscribePresence(this.events(), contextId, handler, codec);
8547
+ }
8548
+ /** Stop every resend. Entries expire on the relay and its peers within 7 s. */
8549
+ close() {
8550
+ for (const contextId of /* @__PURE__ */ new Set([...this.timers.keys(), ...this.generation.keys()])) {
8551
+ this.bump(contextId);
8552
+ }
8553
+ }
8554
+ /** Stop the context's resends and invalidate any `set` still in flight. */
8555
+ bump(contextId) {
8556
+ this.stop(contextId);
8557
+ const next = (this.generation.get(contextId) ?? 0) + 1;
8558
+ this.generation.set(contextId, next);
8559
+ return next;
8560
+ }
8561
+ stop(contextId) {
8562
+ const timer = this.timers.get(contextId);
8563
+ if (timer !== void 0) clearInterval(timer);
8564
+ this.timers.delete(contextId);
8565
+ }
8566
+ /** Wall-clock seeded and strictly rising, so a relay restart never sees an old seq. */
8567
+ nextSeq(contextId) {
8568
+ const seq = Math.max(this.now(), (this.lastSeq.get(contextId) ?? 0) + 1);
8569
+ this.lastSeq.set(contextId, seq);
8570
+ return seq;
8571
+ }
8572
+ async send(contextId, state) {
8573
+ if (this.refused.has(contextId)) return;
8574
+ const signer = await this.relay.authorSigner();
8575
+ const seq = this.nextSeq(contextId);
8576
+ const sentAtMs = this.now();
8577
+ const statement = await presenceStatementBytes({
8578
+ contextId,
8579
+ author: signer.publicKey,
8580
+ seq: BigInt(seq),
8581
+ sentAtMs: BigInt(sentAtMs),
8582
+ state
8583
+ });
8584
+ const signature = hex(await signer.sign(statement));
8585
+ try {
8586
+ await this.relay.presenceIntent(contextId, {
8587
+ state: state === null ? null : hex(state),
8588
+ seq,
8589
+ sentAtMs,
8590
+ signature,
8591
+ authorProof: this.relay.authorProof
8592
+ });
8593
+ } catch (err) {
8594
+ const status = err.status;
8595
+ if (status === void 0 || status === 0 || status === 429 || status >= 500) return;
8596
+ this.refused.add(contextId);
8597
+ this.stop(contextId);
8598
+ throw err;
8599
+ }
8600
+ }
8601
+ };
8602
+
8470
8603
  // src/transport/mero-client.ts
8471
8604
  function relayHasNo(surface, why) {
8472
8605
  return new Error(
@@ -8510,6 +8643,7 @@ var MeroClient = class {
8510
8643
  this.proofAuthorizer = null;
8511
8644
  this.relayUrl = null;
8512
8645
  this.proofEvents = null;
8646
+ this.relayPresence = null;
8513
8647
  if (config.transport === "relay") {
8514
8648
  this.transport = "relay";
8515
8649
  this.nodeClient = null;
@@ -8664,26 +8798,19 @@ var MeroClient = class {
8664
8798
  /**
8665
8799
  * Ephemeral presence. Node transport only.
8666
8800
  *
8667
- * Not extended to the relay even though a logged-in session could reach both
8668
- * halves of it mechanically. Publishing resolves the author from an *owned
8669
- * context identity on the node*, and whether a delegated session has one has
8670
- * not been established — a presence client that silently published nothing,
8671
- * or published as the wrong identity, is worse than one that says it is not
8672
- * available. The read half is not lost: presence arrives on `client.events`
8673
- * like any other context event, and can be filtered there.
8674
- *
8675
- * The rejected alternative was to expose it over the relay session and let
8676
- * `set` fail at the node. That trades a clear construction-time answer for a
8677
- * runtime failure inside a UI that has already rendered.
8801
+ * On a relay client this is an account's presence client
8802
+ * ({@link RelayPresenceClient}): `set` signs each update with the device key
8803
+ * and posts it to the relay's `presence-intents`, resending every 2.5 s, and
8804
+ * `subscribe` reads the relay's event stream. The relay checks and forwards
8805
+ * the update; other members verify the device's signature and certificate
8806
+ * themselves, so it can drop or delay an account's presence but not invent or
8807
+ * alter it. Only `subscribe` needs the relay's node key (for the event
8808
+ * stream), so publishing works on any relay client.
8678
8809
  */
8679
8810
  get ephemeral() {
8680
- if (!this.nodeClient) {
8681
- throw relayHasNo(
8682
- "ephemeral presence",
8683
- "publishing resolves the author from an owned context identity on the node, which a delegated session is not known to have \u2014 read presence off `client.events` instead"
8684
- );
8685
- }
8686
- return this.nodeClient.ephemeral;
8811
+ if (this.nodeClient) return this.nodeClient.ephemeral;
8812
+ this.relayPresence ?? (this.relayPresence = new RelayPresenceClient({ relay: this.relay, events: () => this.events }));
8813
+ return this.relayPresence;
8687
8814
  }
8688
8815
  /**
8689
8816
  * Calimero Cloud.
@@ -10899,9 +11026,11 @@ export {
10899
11026
  MeroJs,
10900
11027
  NODE_RELEASE_SIGNER,
10901
11028
  OWNER_OP_KIND,
11029
+ PRESENCE_DOMAIN,
10902
11030
  REQUEST_PROOF_HEADER,
10903
11031
  RelayClient,
10904
11032
  RelayObserver,
11033
+ RelayPresenceClient,
10905
11034
  RelayTransport,
10906
11035
  RpcClient,
10907
11036
  RpcError,
@@ -11021,6 +11150,7 @@ export {
11021
11150
  parseDeviceCredential,
11022
11151
  parseGovernanceWarrant,
11023
11152
  parseWarrantNonce,
11153
+ presenceStatementBytes,
11024
11154
  probeNodeHealth,
11025
11155
  readCloudLinkCallback,
11026
11156
  readEnrolmentCallback,
@@ -11050,8 +11180,10 @@ export {
11050
11180
  signedPath,
11051
11181
  signerFromCryptoKey,
11052
11182
  signerFromSecret,
11183
+ stateHash,
11053
11184
  subgroupCreation,
11054
11185
  subgroupVisibilitySetOp,
11186
+ subscribePresence,
11055
11187
  teeAdmissionPolicyOp,
11056
11188
  teeAuthoringPolicyOp,
11057
11189
  teeReleaseAdmissionPolicyOp,