@calimero-network/mero-js 25.0.2 → 25.0.4

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.mjs CHANGED
@@ -9906,15 +9906,70 @@ function base643(bytes) {
9906
9906
  return btoa(binary);
9907
9907
  }
9908
9908
 
9909
+ // src/cloud/link-state.ts
9910
+ var KEY2 = "calimero.cloud.link.pending";
9911
+ var STATE_PARAM = "link_state";
9912
+ var LIFETIME_MS = 10 * 60 * 1e3;
9913
+ var ACCOUNT_HEX = /^[0-9a-f]{64}$/;
9914
+ var MAX_PENDING = 20;
9915
+ function readPending(now) {
9916
+ try {
9917
+ const raw = localStorage.getItem(KEY2);
9918
+ const pending = raw ? JSON.parse(raw) : {};
9919
+ return Object.fromEntries(
9920
+ Object.entries(pending).filter(
9921
+ ([, p]) => p && typeof p.expiresAt === "number" && p.expiresAt > now
9922
+ )
9923
+ );
9924
+ } catch {
9925
+ return {};
9926
+ }
9927
+ }
9928
+ function writePending(pending) {
9929
+ try {
9930
+ localStorage.setItem(KEY2, JSON.stringify(pending));
9931
+ } catch {
9932
+ }
9933
+ }
9934
+ function beginLinkState(account, returnTo) {
9935
+ if (!ACCOUNT_HEX.test(account)) {
9936
+ throw new Error("accountId must be 64 lowercase hex characters");
9937
+ }
9938
+ const url = new URL(returnTo);
9939
+ const now = Date.now();
9940
+ const pending = readPending(now);
9941
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
9942
+ const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
9943
+ ""
9944
+ );
9945
+ pending[state] = { account, expiresAt: now + LIFETIME_MS };
9946
+ writePending(Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
9947
+ url.searchParams.set(STATE_PARAM, state);
9948
+ return url.toString();
9949
+ }
9950
+ function takeLinkState(search) {
9951
+ const state = new URLSearchParams(search).get(STATE_PARAM);
9952
+ const pending = readPending(Date.now());
9953
+ if (!state || !Object.prototype.hasOwnProperty.call(pending, state))
9954
+ return null;
9955
+ const entry = pending[state];
9956
+ delete pending[state];
9957
+ writePending(pending);
9958
+ return entry;
9959
+ }
9960
+ function withoutLinkState(search) {
9961
+ const params = new URLSearchParams(search);
9962
+ params.delete(STATE_PARAM);
9963
+ const rest = params.toString();
9964
+ return rest ? `?${rest}` : "";
9965
+ }
9966
+
9909
9967
  // src/cloud/link-redirect.ts
9910
9968
  var DEFAULT_CLOUD_WEB_URL = "https://cloud.calimero.network";
9911
9969
  function cloudLinkUrl(options) {
9912
- if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
9913
- throw new Error("accountId must be 64 lowercase hex characters");
9914
- }
9915
9970
  const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
9916
9971
  url.searchParams.set("link-account", options.accountId);
9917
- url.searchParams.set("callback-url", options.returnTo);
9972
+ url.searchParams.set("callback-url", beginLinkState(options.accountId, options.returnTo));
9918
9973
  return url.toString();
9919
9974
  }
9920
9975
  function beginCloudLink(options) {
@@ -9925,6 +9980,7 @@ function readCloudLinkCallback(location2 = window.location) {
9925
9980
  const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
9926
9981
  const error = params.get("error");
9927
9982
  if (error) {
9983
+ takeLinkState(location2.search ?? "");
9928
9984
  stripFragment();
9929
9985
  throw new Error(
9930
9986
  error === "denied" ? "the account link was declined at the cloud" : `the cloud refused this link: ${error}`
@@ -9933,12 +9989,18 @@ function readCloudLinkCallback(location2 = window.location) {
9933
9989
  const grant = params.get("grant");
9934
9990
  const account = params.get("account");
9935
9991
  if (!grant || !account) return null;
9992
+ if (takeLinkState(location2.search ?? "")?.account !== account) {
9993
+ stripFragment();
9994
+ throw new Error(
9995
+ "this account link answers a request this browser did not make - start the link again from here"
9996
+ );
9997
+ }
9936
9998
  stripFragment();
9937
9999
  return { grant, account };
9938
10000
  }
9939
10001
  function stripFragment() {
9940
10002
  if (typeof history === "undefined" || !history.replaceState) return;
9941
- history.replaceState(null, "", location.pathname + location.search);
10003
+ history.replaceState(null, "", location.pathname + withoutLinkState(location.search));
9942
10004
  }
9943
10005
  async function completeCloudLink(options) {
9944
10006
  const root = await resolveRoot(options.root);
@@ -9958,7 +10020,7 @@ async function completeCloudLink(options) {
9958
10020
  var DEVICE_PARAM = "enrol-device";
9959
10021
  var KEM_PARAM = "enrol-kem";
9960
10022
  var CALLBACK_PARAM = "callback-url";
9961
- var STATE_PARAM = "state";
10023
+ var STATE_PARAM2 = "state";
9962
10024
  function deviceEnrolmentUrl(options) {
9963
10025
  assertHex(options.devicePublicKey, "devicePublicKey");
9964
10026
  assertHex(options.kemPublicKey, "kemPublicKey");
@@ -9968,7 +10030,7 @@ function deviceEnrolmentUrl(options) {
9968
10030
  url.searchParams.set(DEVICE_PARAM, options.devicePublicKey);
9969
10031
  url.searchParams.set(KEM_PARAM, options.kemPublicKey);
9970
10032
  url.searchParams.set(CALLBACK_PARAM, options.returnTo);
9971
- if (options.state) url.searchParams.set(STATE_PARAM, options.state);
10033
+ if (options.state) url.searchParams.set(STATE_PARAM2, options.state);
9972
10034
  return url.toString();
9973
10035
  }
9974
10036
  function beginDeviceEnrolment(options) {
@@ -10075,17 +10137,6 @@ function normaliseAccount(account) {
10075
10137
  const bare = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
10076
10138
  return bare.length > 0 ? bare : null;
10077
10139
  }
10078
- function httpAdmitterOrigins(addrs) {
10079
- const out = [];
10080
- for (const addr of addrs ?? []) {
10081
- try {
10082
- const url = new URL(addr);
10083
- if (url.protocol === "http:" || url.protocol === "https:") out.push(addr.replace(/\/+$/, ""));
10084
- } catch {
10085
- }
10086
- }
10087
- return out;
10088
- }
10089
10140
  function isInvited(node, invited) {
10090
10141
  if (invited.size === 0) return true;
10091
10142
  const account = normaliseAccount(node.account);
@@ -10110,23 +10161,6 @@ async function resolveRelayFromInvitation(input) {
10110
10161
  };
10111
10162
  }
10112
10163
  if (nodes.length === 0) {
10113
- const [relayUrl] = httpAdmitterOrigins(input.admitterAddrs);
10114
- if (relayUrl) {
10115
- const admitters = (input.admitters ?? []).map(normaliseAccount).filter((a) => a !== null);
10116
- return {
10117
- ok: true,
10118
- relayUrl,
10119
- admitUrl: `${relayUrl}/admin-api/namespaces/${input.namespaceId}/admit`,
10120
- admitterAccount: admitters.length === 1 ? admitters[0] : null,
10121
- peerId: null,
10122
- // The relay a founder's session runs through executes that founder's
10123
- // warrants, so it authors on behalf; the node's answer to the first
10124
- // write is the authoritative one, as for a cloud-routed node.
10125
- writable: true,
10126
- stale: false,
10127
- via: "invitation"
10128
- };
10129
- }
10130
10164
  return {
10131
10165
  ok: false,
10132
10166
  step: "no-nodes",
@@ -10170,8 +10204,7 @@ async function resolveRelayFromInvitation(input) {
10170
10204
  // Stale when the node's own heartbeat lapsed OR when the cloud's summary
10171
10205
  // disagrees with trying it at all. Both mean the same thing to a caller:
10172
10206
  // this was attempted on advisory data, so if the admit fails that is why.
10173
- stale: !chosen.fresh || advisory,
10174
- via: "cloud"
10207
+ stale: !chosen.fresh || advisory
10175
10208
  };
10176
10209
  }
10177
10210
 
@@ -10457,7 +10490,6 @@ async function bootstrapFromInvitation(input) {
10457
10490
  // separately: two sources for one fact is how an intersection ends up
10458
10491
  // being performed against the wrong list.
10459
10492
  admitters: input.invitation.invitation.admitters,
10460
- admitterAddrs: input.invitation.admitter_addrs,
10461
10493
  credential: input.credential.credential,
10462
10494
  deviceSecret: input.credential.deviceSecret,
10463
10495
  cloudBaseUrl: input.cloudBaseUrl
@@ -10676,7 +10708,7 @@ async function attestedNodeKey(relay) {
10676
10708
  key.kind === "unavailable" ? "the relay's signing key could not be learned: its attestation did not answer, try again" : "the relay's attestation was refused, so founding through it is not possible: it is not a signed locked-read-only release"
10677
10709
  );
10678
10710
  }
10679
- var HA_ACCOUNT_NOT_LINKED_MESSAGE = "link this account to your cloud user in the wallet so invitees can find this namespace";
10711
+ var HA_ACCOUNT_NOT_LINKED_MESSAGE = "link this account to your cloud user in the wallet so the cloud can host this namespace on its fleet";
10680
10712
  var HA_REFUSAL_MESSAGES = {
10681
10713
  account_not_linked: HA_ACCOUNT_NOT_LINKED_MESSAGE,
10682
10714
  account_linked_to_several_users: "this account is linked to more than one cloud user, so the cloud cannot tell whose plan hosts the namespace: unlink it from all but one in the wallet",
@@ -10781,7 +10813,7 @@ var NoRelayError = class extends Error {
10781
10813
  var InvitationNotClaimableError = class extends Error {
10782
10814
  constructor(namespaceId, reason) {
10783
10815
  super(
10784
- reason === "not-hosted" ? `nobody could claim an invitation to ${namespaceId} yet: the namespace is not hosted in the cloud and has no relay in it, so an invitee with no node has no relay to be admitted through. Link this account to your cloud user in the wallet and enable HA for the namespace, then invite.` : `nobody could claim an invitation to ${namespaceId}: the cloud routes to none of the relays or admins it would name as admitters`
10816
+ reason === "not-hosted" ? `nobody could claim an invitation to ${namespaceId} yet: the cloud lists no node holding the namespace, so an invitee with no node of their own has no relay to be admitted through.` : `nobody could claim an invitation to ${namespaceId}: the cloud routes to none of the relays or admins it would name as admitters`
10785
10817
  );
10786
10818
  this.namespaceId = namespaceId;
10787
10819
  this.reason = reason;
@@ -10969,7 +11001,6 @@ function createAccountAdmin(input, deps = {}) {
10969
11001
  async createNamespaceInvitation(namespaceId) {
10970
11002
  const [{ members }, info] = await Promise.all([relay().listGroupMembers(namespaceId), relay().getGroupInfo(namespaceId)]);
10971
11003
  const relays = members.filter((m) => m.role === "RelayTee").map((m) => m.identity.toLowerCase());
10972
- let admitterAddrs;
10973
11004
  if (routing) {
10974
11005
  const named = new Set(
10975
11006
  relays.length > 0 ? relays : members.filter((m) => m.role === "Admin").map((m) => m.identity.toLowerCase())
@@ -10980,10 +11011,8 @@ function createAccountAdmin(input, deps = {}) {
10980
11011
  } catch {
10981
11012
  }
10982
11013
  if (nodes !== null) {
10983
- if (nodes.length === 0) {
10984
- if (relays.length === 0 || !s.relayUrl) throw new InvitationNotClaimableError(namespaceId, "not-hosted");
10985
- admitterAddrs = [s.relayUrl];
10986
- } else if (!nodes.some((n) => n.account !== null && named.has(n.account.toLowerCase()))) {
11014
+ if (nodes.length === 0) throw new InvitationNotClaimableError(namespaceId, "not-hosted");
11015
+ if (!nodes.some((n) => n.account !== null && named.has(n.account.toLowerCase()))) {
10987
11016
  throw new InvitationNotClaimableError(namespaceId, "no-named-node");
10988
11017
  }
10989
11018
  }
@@ -10993,7 +11022,6 @@ function createAccountAdmin(input, deps = {}) {
10993
11022
  inviterAccount: s.account,
10994
11023
  deviceSecret: s.deviceSecret,
10995
11024
  ...relays.length > 0 ? { admitters: relays } : {},
10996
- ...admitterAddrs ? { admitterAddrs } : {},
10997
11025
  members,
10998
11026
  applicationId: info.targetApplicationId,
10999
11027
  appKey: info.appKey
@@ -11100,7 +11128,6 @@ function joinOf(row) {
11100
11128
  reportedAt: typeof join.reported_at === "string" ? join.reported_at : null
11101
11129
  };
11102
11130
  }
11103
- var ACCOUNT_HEX = /^[0-9a-f]{64}$/;
11104
11131
  function accountHexOrNull(value) {
11105
11132
  return typeof value === "string" && ACCOUNT_HEX.test(value) ? value : null;
11106
11133
  }
@@ -11374,9 +11401,8 @@ var CloudClient = class {
11374
11401
  * are answered together rather than across two endpoints — the per-context
11375
11402
  * read needs a context id a joiner does not yet have.
11376
11403
  *
11377
- * An empty `nodes` list is a state, not an error: a namespace with no fleet
11378
- * assignment has no cloud node, and the joiner must use one of the
11379
- * invitation's other admitters — another admin's node, or a self-hosted peer.
11404
+ * An empty `nodes` list is a state, not an error: the cloud has no record of
11405
+ * a node holding the namespace, so there is no hosted admitter.
11380
11406
  */
11381
11407
  async getNamespaceRouting(namespaceId) {
11382
11408
  const body = await this.request("GET", `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/admitters`, {
@@ -11829,7 +11855,7 @@ var CloudClient = class {
11829
11855
  const portal = options.portalUrl.replace(/\/+$/, "");
11830
11856
  const query = new URLSearchParams({
11831
11857
  "link-account": options.accountId,
11832
- "callback-url": options.callbackUrl
11858
+ "callback-url": beginLinkState(options.accountId, options.callbackUrl)
11833
11859
  });
11834
11860
  return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
11835
11861
  }
@@ -11839,14 +11865,19 @@ var CloudClient = class {
11839
11865
  * Returns `{}` when there is nothing to read, so it is safe to call on every
11840
11866
  * load — which is how a redirected app finds out it has been sent back.
11841
11867
  *
11868
+ * A grant is only returned when it answers a handoff this browser began, for the
11869
+ * account that handoff named; anything else comes back as
11870
+ * `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
11871
+ *
11842
11872
  * @param href defaults to the current location, when there is one.
11843
11873
  */
11844
11874
  static readAccountLinkCallback(href) {
11845
11875
  const source = href ?? (typeof window === "undefined" ? "" : window.location.href);
11846
11876
  if (!source) return {};
11847
11877
  let hash;
11878
+ let search;
11848
11879
  try {
11849
- hash = new URL(source).hash;
11880
+ ({ hash, search } = new URL(source));
11850
11881
  } catch {
11851
11882
  return {};
11852
11883
  }
@@ -11855,6 +11886,10 @@ var CloudClient = class {
11855
11886
  const accountId = params.get("account");
11856
11887
  const error = params.get("error");
11857
11888
  const out = {};
11889
+ if (error) takeLinkState(search);
11890
+ if (grant && takeLinkState(search)?.account !== accountId) {
11891
+ return { error: "unexpected_callback" };
11892
+ }
11858
11893
  if (grant) out.grant = grant;
11859
11894
  if (accountId) out.accountId = accountId;
11860
11895
  if (error) out.error = error;