@calimero-network/mero-js 19.8.1 → 19.10.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
@@ -78,6 +78,7 @@ __export(index_exports, {
78
78
  probeNodeHealth: () => probeNodeHealth,
79
79
  routingProofHeaders: () => routingProofHeaders,
80
80
  signAccountLink: () => signAccountLink,
81
+ signAccountLogin: () => signAccountLogin,
81
82
  signDeviceCert: () => signDeviceCert,
82
83
  signLoginStatement: () => signLoginStatement,
83
84
  signMemberJoinOp: () => signMemberJoinOp,
@@ -127,10 +128,13 @@ var TERMINAL_AUTH_ERRORS = /* @__PURE__ */ new Set(["token_reuse", "token_revoke
127
128
  function extractErrorMessage(bodyText) {
128
129
  if (!bodyText) return void 0;
129
130
  try {
130
- const error = JSON.parse(bodyText)?.error;
131
+ const body = JSON.parse(bodyText);
132
+ const error = body?.error;
131
133
  if (typeof error === "string" && error.trim() !== "") return error;
132
134
  const nested = error?.message;
133
135
  if (typeof nested === "string" && nested.trim() !== "") return nested;
136
+ const detail = body?.detail;
137
+ if (typeof detail === "string" && detail.trim() !== "") return detail;
134
138
  } catch {
135
139
  }
136
140
  return void 0;
@@ -4752,6 +4756,9 @@ var ENGLISH_WORDLIST = [
4752
4756
  var ACCOUNT_LINK_DOMAIN = new TextEncoder().encode(
4753
4757
  "calimero.mdma.account-link.v1\0"
4754
4758
  );
4759
+ var ACCOUNT_LOGIN_DOMAIN = new TextEncoder().encode(
4760
+ "calimero.mdma.account-login.v1\0"
4761
+ );
4755
4762
  var ENTROPY_BITS = 256;
4756
4763
  var CHECKSUM_BITS = ENTROPY_BITS / 32;
4757
4764
  var PHRASE_WORDS = (ENTROPY_BITS + CHECKSUM_BITS) / 11;
@@ -4783,6 +4790,18 @@ async function signAccountLink(input) {
4783
4790
  );
4784
4791
  return base64(signature);
4785
4792
  }
4793
+ async function signAccountLogin(input) {
4794
+ if (!input.nonce) throw new Error("nonce is required to sign an account login");
4795
+ const key = await importSigningKey(input.rootSecret);
4796
+ const signature = new Uint8Array(
4797
+ await crypto.subtle.sign(
4798
+ "Ed25519",
4799
+ key,
4800
+ concat(ACCOUNT_LOGIN_DOMAIN, new TextEncoder().encode(input.nonce))
4801
+ )
4802
+ );
4803
+ return base64(signature);
4804
+ }
4786
4805
  async function withPhrase(secret, phrase) {
4787
4806
  return { ...await accountRootFromSecret(secret), phrase };
4788
4807
  }
@@ -4913,6 +4932,70 @@ var CloudClient = class {
4913
4932
  const body = await this.request("POST", "/api/auth/google", { body: { id_token: idToken }, anonymous: true });
4914
4933
  return this.adoptSession(body);
4915
4934
  }
4935
+ /**
4936
+ * Sign in as a Calimero account, proving it with its root key.
4937
+ *
4938
+ * The password-free counterpart to {@link signInWithGoogle}: no Google, no
4939
+ * password, no server-held secret — the cloud mints a challenge, the account
4940
+ * root signs it, and the session that comes back is the same MDMA session
4941
+ * token every other cloud route already accepts.
4942
+ *
4943
+ * Doing this **once** is what makes an account's later device proofs mean
4944
+ * something. A routing read shows that the caller holds a device the root
4945
+ * certified, but certificates are public, so it can never show that anyone
4946
+ * held the root; the cloud records this claim and from then on knows the
4947
+ * account behind those reads was claimed by its owner. The claim is recorded
4948
+ * even when the session is refused.
4949
+ *
4950
+ * @param rootSecret the account root's signing secret, 64 hex. It never
4951
+ * leaves this process — only a signature over the cloud's nonce travels.
4952
+ *
4953
+ * A `403` means either the signature did not verify, or the account is not
4954
+ * linked to a cloud login: ownership alone says who you are, and the link
4955
+ * says what you are entitled to. Anyone can mint a root offline, so a session
4956
+ * on the proof alone would authenticate perfectly and authorize nothing. The
4957
+ * ownership claim is recorded either way, so linking later needs no re-proof.
4958
+ */
4959
+ async signInWithAccount(rootSecret) {
4960
+ const root = await accountRootFromSecret(rootSecret);
4961
+ const { nonce } = await this.getAccountLoginChallenge();
4962
+ return this.submitAccountLogin({
4963
+ rootPublicKey: root.publicKey,
4964
+ nonce,
4965
+ signature: await signAccountLogin({ rootSecret, nonce })
4966
+ });
4967
+ }
4968
+ /**
4969
+ * Mint a challenge for an account root to sign.
4970
+ *
4971
+ * Public, and deliberately so: the caller has no session — obtaining one is
4972
+ * what this exchange does. The nonce is not a capability, it is the thing a
4973
+ * capability gets demonstrated against, and demonstrating it needs the root.
4974
+ *
4975
+ * Split out of {@link signInWithAccount} for the case that call cannot serve:
4976
+ * a root held by a hardware key, a desktop app or an air-gapped machine,
4977
+ * which signs without the secret ever reaching this process.
4978
+ */
4979
+ async getAccountLoginChallenge() {
4980
+ const body = await this.request(
4981
+ "GET",
4982
+ "/api/auth/account/challenge",
4983
+ { anonymous: true }
4984
+ );
4985
+ return { nonce: body.nonce, expiresAtMs: body.expires_at_ms };
4986
+ }
4987
+ /** Post a proof assembled elsewhere. The other half of {@link getAccountLoginChallenge}. */
4988
+ async submitAccountLogin(proof) {
4989
+ const body = await this.request("POST", "/api/auth/account", {
4990
+ body: {
4991
+ root_public_key: proof.rootPublicKey,
4992
+ nonce: proof.nonce,
4993
+ signature: proof.signature
4994
+ },
4995
+ anonymous: true
4996
+ });
4997
+ return this.adoptSession(body);
4998
+ }
4916
4999
  /**
4917
5000
  * Refresh the session token explicitly.
4918
5001
  *
@@ -5286,6 +5369,100 @@ var CloudClient = class {
5286
5369
  alreadyLinked: body.already_linked === true
5287
5370
  };
5288
5371
  }
5372
+ /**
5373
+ * Where to send someone to authorise linking this account, and what to expect
5374
+ * back. Pairs with {@link readAccountLinkCallback} and
5375
+ * {@link linkAccountWithGrant}.
5376
+ *
5377
+ * This exists because a browser app is in a bind: it holds an account root and
5378
+ * can never hold a *first* cloud session, since only Google issues one. So the
5379
+ * person goes to the cloud, where they are signed in, and consents there. What
5380
+ * comes back is a grant — consent to link this one account, spendable only by
5381
+ * whoever can sign with its root.
5382
+ *
5383
+ * @param callbackUrl where the portal sends the person back. Must be an origin
5384
+ * the cloud allows; unlisted ones are refused when the grant is minted, not
5385
+ * here. The grant arrives in the URL **fragment**, which browsers do not
5386
+ * send to servers.
5387
+ *
5388
+ * Static, so it needs no client and no session: there is nothing to
5389
+ * authenticate as yet.
5390
+ */
5391
+ static accountLinkHandoff(options) {
5392
+ const portal = options.portalUrl.replace(/\/+$/, "");
5393
+ const query = new URLSearchParams({
5394
+ "link-account": options.accountId,
5395
+ "callback-url": options.callbackUrl
5396
+ });
5397
+ return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
5398
+ }
5399
+ /**
5400
+ * Read the portal's answer out of a callback URL's fragment.
5401
+ *
5402
+ * Returns `{}` when there is nothing to read, so it is safe to call on every
5403
+ * load — which is how a redirected app finds out it has been sent back.
5404
+ *
5405
+ * @param href defaults to the current location, when there is one.
5406
+ */
5407
+ static readAccountLinkCallback(href) {
5408
+ const source = href ?? (typeof window === "undefined" ? "" : window.location.href);
5409
+ if (!source) return {};
5410
+ let hash;
5411
+ try {
5412
+ hash = new URL(source).hash;
5413
+ } catch {
5414
+ return {};
5415
+ }
5416
+ const params = new URLSearchParams(hash.replace(/^#/, ""));
5417
+ const grant = params.get("grant");
5418
+ const accountId = params.get("account");
5419
+ const error = params.get("error");
5420
+ const out = {};
5421
+ if (grant) out.grant = grant;
5422
+ if (accountId) out.accountId = accountId;
5423
+ if (error) out.error = error;
5424
+ return out;
5425
+ }
5426
+ /**
5427
+ * Spend a link grant: sign it with the account root and post the pair.
5428
+ *
5429
+ * Anonymous, and it has to be — the app doing this holds no cloud session,
5430
+ * which is the whole reason the grant exists. The grant carries the consenting
5431
+ * login sealed inside it, so nothing here names one; a caller-supplied email
5432
+ * would be a field to lie in.
5433
+ *
5434
+ * The signature is an ordinary account-link signature with the grant as the
5435
+ * nonce, so the same {@link signAccountLink} every other link path uses signs
5436
+ * this one. Both halves are required: a grant without the key links nothing,
5437
+ * which is what makes it safe to carry through a browser redirect.
5438
+ *
5439
+ * A `403` means the grant was bad, expired, already spent, or signed by a key
5440
+ * that names a different account; `409` that the account belongs to another
5441
+ * login; `402` that the plan is full.
5442
+ */
5443
+ async linkAccountWithGrant(options) {
5444
+ const root = await accountRootFromSecret(options.rootSecret);
5445
+ const body = await this.request(
5446
+ "POST",
5447
+ "/api/cloud/accounts/link",
5448
+ {
5449
+ body: {
5450
+ grant: options.grant,
5451
+ root_public_key: root.publicKey,
5452
+ signature: await signAccountLink({
5453
+ rootSecret: options.rootSecret,
5454
+ nonce: options.grant
5455
+ })
5456
+ },
5457
+ anonymous: true
5458
+ }
5459
+ );
5460
+ return {
5461
+ accountId: String(body.account_id ?? root.accountId),
5462
+ linkedAt: body.linked_at ?? null,
5463
+ alreadyLinked: body.already_linked === true
5464
+ };
5465
+ }
5289
5466
  /** The accounts linked to this login, and how many the plan allows. */
5290
5467
  async getMyAccounts() {
5291
5468
  const body = await this.request("GET", "/api/cloud/me/accounts");