@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.cjs CHANGED
@@ -39,9 +39,11 @@ __export(index_exports, {
39
39
  MeroJs: () => MeroJs,
40
40
  NODE_RELEASE_SIGNER: () => NODE_RELEASE_SIGNER,
41
41
  OWNER_OP_KIND: () => OWNER_OP_KIND,
42
+ PRESENCE_DOMAIN: () => PRESENCE_DOMAIN,
42
43
  REQUEST_PROOF_HEADER: () => REQUEST_PROOF_HEADER,
43
44
  RelayClient: () => RelayClient,
44
45
  RelayObserver: () => RelayObserver,
46
+ RelayPresenceClient: () => RelayPresenceClient,
45
47
  RelayTransport: () => RelayTransport,
46
48
  RpcClient: () => RpcClient,
47
49
  RpcError: () => RpcError,
@@ -161,6 +163,7 @@ __export(index_exports, {
161
163
  parseDeviceCredential: () => parseDeviceCredential,
162
164
  parseGovernanceWarrant: () => parseGovernanceWarrant,
163
165
  parseWarrantNonce: () => parseWarrantNonce,
166
+ presenceStatementBytes: () => presenceStatementBytes,
164
167
  probeNodeHealth: () => probeNodeHealth,
165
168
  readCloudLinkCallback: () => readCloudLinkCallback,
166
169
  readEnrolmentCallback: () => readEnrolmentCallback,
@@ -190,8 +193,10 @@ __export(index_exports, {
190
193
  signedPath: () => signedPath,
191
194
  signerFromCryptoKey: () => signerFromCryptoKey,
192
195
  signerFromSecret: () => signerFromSecret,
196
+ stateHash: () => stateHash,
193
197
  subgroupCreation: () => subgroupCreation,
194
198
  subgroupVisibilitySetOp: () => subgroupVisibilitySetOp,
199
+ subscribePresence: () => subscribePresence,
195
200
  teeAdmissionPolicyOp: () => teeAdmissionPolicyOp,
196
201
  teeAuthoringPolicyOp: () => teeAuthoringPolicyOp,
197
202
  teeReleaseAdmissionPolicyOp: () => teeReleaseAdmissionPolicyOp,
@@ -3501,28 +3506,32 @@ var EphemeralClient = class {
3501
3506
  * and zero mean different things). Returns an unsubscribe function.
3502
3507
  */
3503
3508
  subscribe(contextId, handler, codec = jsonCodec()) {
3504
- const listener = (event) => {
3505
- const e = event;
3506
- if (e.type !== "Ephemeral" || e.contextId !== contextId) return;
3507
- const data = e.data;
3508
- if (!data?.author) return;
3509
- const removed = Boolean(data.removed);
3510
- const entry = {
3511
- author: data.author,
3512
- state: removed || data.state === void 0 ? void 0 : codec.decode(data.state),
3513
- removed
3514
- };
3515
- if (data.ageMs !== void 0) entry.ageMs = data.ageMs;
3516
- handler(entry);
3517
- };
3518
- this.sse.on("event", listener);
3519
- void this.sse.connect().catch(() => void 0);
3520
- void this.sse.subscribe([contextId]).catch(() => void 0);
3521
- return () => {
3522
- this.sse.off("event", listener);
3523
- };
3509
+ return subscribePresence(this.sse, contextId, handler, codec);
3524
3510
  }
3525
3511
  };
3512
+ function subscribePresence(sse, contextId, handler, codec = jsonCodec()) {
3513
+ const listener = (event) => {
3514
+ const e = event;
3515
+ if (e.type !== "Ephemeral" || e.contextId !== contextId) return;
3516
+ const data = e.data;
3517
+ if (!data?.author) return;
3518
+ const removed = Boolean(data.removed);
3519
+ const entry = {
3520
+ author: data.author,
3521
+ state: removed || data.state === void 0 ? void 0 : codec.decode(data.state),
3522
+ removed
3523
+ };
3524
+ if (data.ageMs !== void 0) entry.ageMs = data.ageMs;
3525
+ if (data.account !== void 0) entry.account = data.account;
3526
+ handler(entry);
3527
+ };
3528
+ sse.on("event", listener);
3529
+ void sse.connect().catch(() => void 0);
3530
+ void sse.subscribe([contextId]).catch(() => void 0);
3531
+ return () => {
3532
+ sse.off("event", listener);
3533
+ };
3534
+ }
3526
3535
 
3527
3536
  // src/signer/signer.ts
3528
3537
  var PKCS8_ED25519_PREFIX2 = new Uint8Array([
@@ -7948,6 +7957,19 @@ var RelayClient = class {
7948
7957
  );
7949
7958
  return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
7950
7959
  }
7960
+ /**
7961
+ * Publish one presence statement through the relay (`presence-intents`).
7962
+ *
7963
+ * Not retried on 429: presence resends every 2.5 s anyway, and a retry would
7964
+ * only queue behind the next resend. A refusal throws with its `status`.
7965
+ */
7966
+ async presenceIntent(contextId, body) {
7967
+ await this.jsonOnce(
7968
+ "POST",
7969
+ `/admin-api/contexts/${encodeURIComponent(contextId)}/presence-intents`,
7970
+ body
7971
+ );
7972
+ }
7951
7973
  /**
7952
7974
  * Ask the relay what it can do about creating contexts in `groupId`.
7953
7975
  *
@@ -8249,6 +8271,7 @@ var RelayClient = class {
8249
8271
  text
8250
8272
  );
8251
8273
  }
8274
+ if (response.status === 204) return void 0;
8252
8275
  return await response.json();
8253
8276
  }
8254
8277
  };
@@ -8676,6 +8699,121 @@ function signedPath(url) {
8676
8699
  return new URL(url).pathname;
8677
8700
  }
8678
8701
 
8702
+ // src/presence/statement.ts
8703
+ var PRESENCE_DOMAIN = new TextEncoder().encode("calimero/presence/1");
8704
+ async function stateHash(state) {
8705
+ const borsh = state === null ? new Uint8Array([0]) : concat(new Uint8Array([1]), u32le(state.length), state);
8706
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", borsh));
8707
+ }
8708
+ async function presenceStatementBytes(input) {
8709
+ return concat(
8710
+ PRESENCE_DOMAIN,
8711
+ fromHex(input.contextId, "contextId", 32),
8712
+ fromHex(input.author, "author", 32),
8713
+ u64le(input.seq),
8714
+ u64le(input.sentAtMs),
8715
+ await stateHash(input.state)
8716
+ );
8717
+ }
8718
+
8719
+ // src/presence/relay-presence.ts
8720
+ var HEARTBEAT_MS = 2500;
8721
+ var RelayPresenceClient = class {
8722
+ /**
8723
+ * @param opts.events the relay's event stream, asked for only on `subscribe`:
8724
+ * publishing needs none, so a client with no node key can still publish.
8725
+ */
8726
+ constructor(opts) {
8727
+ this.timers = /* @__PURE__ */ new Map();
8728
+ this.lastSeq = /* @__PURE__ */ new Map();
8729
+ this.refused = /* @__PURE__ */ new Set();
8730
+ /**
8731
+ * Bumped by every `set` and `close`, per context. A `set` installs its
8732
+ * resends only if nothing newer happened while its first send was in flight,
8733
+ * so a retract (or a newer state) can never be overtaken by an older one.
8734
+ */
8735
+ this.generation = /* @__PURE__ */ new Map();
8736
+ this.relay = opts.relay;
8737
+ this.events = opts.events;
8738
+ this.heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
8739
+ this.now = opts.now ?? Date.now;
8740
+ }
8741
+ /**
8742
+ * Set this account's presence in `contextId`, or retract it with `null`.
8743
+ * Throws when the relay refuses this first send for good (a 4xx other than
8744
+ * 429), which also stops the resends. A rate limit, a network failure or a
8745
+ * 5xx is not thrown: the next resend covers it.
8746
+ */
8747
+ async set(contextId, state, codec = jsonCodec()) {
8748
+ const generation = this.bump(contextId);
8749
+ this.refused.delete(contextId);
8750
+ const bytes = state === null ? null : new Uint8Array(codec.encode(state));
8751
+ await this.send(contextId, bytes);
8752
+ if (bytes === null || this.refused.has(contextId)) return;
8753
+ if (this.generation.get(contextId) !== generation) return;
8754
+ this.timers.set(
8755
+ contextId,
8756
+ setInterval(() => void this.send(contextId, bytes).catch(() => void 0), this.heartbeatMs)
8757
+ );
8758
+ }
8759
+ subscribe(contextId, handler, codec = jsonCodec()) {
8760
+ return subscribePresence(this.events(), contextId, handler, codec);
8761
+ }
8762
+ /** Stop every resend. Entries expire on the relay and its peers within 7 s. */
8763
+ close() {
8764
+ for (const contextId of /* @__PURE__ */ new Set([...this.timers.keys(), ...this.generation.keys()])) {
8765
+ this.bump(contextId);
8766
+ }
8767
+ }
8768
+ /** Stop the context's resends and invalidate any `set` still in flight. */
8769
+ bump(contextId) {
8770
+ this.stop(contextId);
8771
+ const next = (this.generation.get(contextId) ?? 0) + 1;
8772
+ this.generation.set(contextId, next);
8773
+ return next;
8774
+ }
8775
+ stop(contextId) {
8776
+ const timer = this.timers.get(contextId);
8777
+ if (timer !== void 0) clearInterval(timer);
8778
+ this.timers.delete(contextId);
8779
+ }
8780
+ /** Wall-clock seeded and strictly rising, so a relay restart never sees an old seq. */
8781
+ nextSeq(contextId) {
8782
+ const seq = Math.max(this.now(), (this.lastSeq.get(contextId) ?? 0) + 1);
8783
+ this.lastSeq.set(contextId, seq);
8784
+ return seq;
8785
+ }
8786
+ async send(contextId, state) {
8787
+ if (this.refused.has(contextId)) return;
8788
+ const signer = await this.relay.authorSigner();
8789
+ const seq = this.nextSeq(contextId);
8790
+ const sentAtMs = this.now();
8791
+ const statement = await presenceStatementBytes({
8792
+ contextId,
8793
+ author: signer.publicKey,
8794
+ seq: BigInt(seq),
8795
+ sentAtMs: BigInt(sentAtMs),
8796
+ state
8797
+ });
8798
+ const signature = hex(await signer.sign(statement));
8799
+ try {
8800
+ await this.relay.presenceIntent(contextId, {
8801
+ state: state === null ? null : hex(state),
8802
+ seq,
8803
+ sentAtMs,
8804
+ signature,
8805
+ authorProof: this.relay.authorProof
8806
+ });
8807
+ } catch (err) {
8808
+ const status = err.status;
8809
+ if (status === void 0 || status === 0 || status === 429 || status >= 500) return;
8810
+ this.refused.add(contextId);
8811
+ this.stop(contextId);
8812
+ throw err;
8813
+ }
8814
+ }
8815
+ };
8816
+
8679
8817
  // src/transport/mero-client.ts
8680
8818
  function relayHasNo(surface, why) {
8681
8819
  return new Error(
@@ -8719,6 +8857,7 @@ var MeroClient = class {
8719
8857
  this.proofAuthorizer = null;
8720
8858
  this.relayUrl = null;
8721
8859
  this.proofEvents = null;
8860
+ this.relayPresence = null;
8722
8861
  if (config.transport === "relay") {
8723
8862
  this.transport = "relay";
8724
8863
  this.nodeClient = null;
@@ -8873,26 +9012,19 @@ var MeroClient = class {
8873
9012
  /**
8874
9013
  * Ephemeral presence. Node transport only.
8875
9014
  *
8876
- * Not extended to the relay even though a logged-in session could reach both
8877
- * halves of it mechanically. Publishing resolves the author from an *owned
8878
- * context identity on the node*, and whether a delegated session has one has
8879
- * not been established — a presence client that silently published nothing,
8880
- * or published as the wrong identity, is worse than one that says it is not
8881
- * available. The read half is not lost: presence arrives on `client.events`
8882
- * like any other context event, and can be filtered there.
8883
- *
8884
- * The rejected alternative was to expose it over the relay session and let
8885
- * `set` fail at the node. That trades a clear construction-time answer for a
8886
- * runtime failure inside a UI that has already rendered.
9015
+ * On a relay client this is an account's presence client
9016
+ * ({@link RelayPresenceClient}): `set` signs each update with the device key
9017
+ * and posts it to the relay's `presence-intents`, resending every 2.5 s, and
9018
+ * `subscribe` reads the relay's event stream. The relay checks and forwards
9019
+ * the update; other members verify the device's signature and certificate
9020
+ * themselves, so it can drop or delay an account's presence but not invent or
9021
+ * alter it. Only `subscribe` needs the relay's node key (for the event
9022
+ * stream), so publishing works on any relay client.
8887
9023
  */
8888
9024
  get ephemeral() {
8889
- if (!this.nodeClient) {
8890
- throw relayHasNo(
8891
- "ephemeral presence",
8892
- "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"
8893
- );
8894
- }
8895
- return this.nodeClient.ephemeral;
9025
+ if (this.nodeClient) return this.nodeClient.ephemeral;
9026
+ this.relayPresence ?? (this.relayPresence = new RelayPresenceClient({ relay: this.relay, events: () => this.events }));
9027
+ return this.relayPresence;
8896
9028
  }
8897
9029
  /**
8898
9030
  * Calimero Cloud.