@calimero-network/mero-js 22.7.0 → 22.8.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.
Files changed (70) hide show
  1. package/dist/account/account.d.ts +99 -4
  2. package/dist/account/account.js +0 -0
  3. package/dist/account/index.d.ts +6 -2
  4. package/dist/account/index.js +3 -1
  5. package/dist/admin-api/admin-client.d.ts +138 -2
  6. package/dist/admin-api/admin-client.js +237 -1
  7. package/dist/admin-api/admin-types.d.ts +159 -5
  8. package/dist/cloud/cloud-client.d.ts +38 -8
  9. package/dist/cloud/cloud-client.js +38 -21
  10. package/dist/cloud/connect.d.ts +101 -2
  11. package/dist/cloud/connect.js +84 -8
  12. package/dist/cloud/enrol-redirect.d.ts +195 -0
  13. package/dist/cloud/enrol-redirect.js +236 -0
  14. package/dist/cloud/index.d.ts +6 -2
  15. package/dist/cloud/index.js +7 -1
  16. package/dist/cloud/link-redirect.d.ts +85 -0
  17. package/dist/cloud/link-redirect.js +105 -0
  18. package/dist/crypto/internal.d.ts +15 -0
  19. package/dist/crypto/internal.js +18 -0
  20. package/dist/device-cert/device-cert.d.ts +85 -2
  21. package/dist/device-cert/device-cert.js +100 -9
  22. package/dist/device-cert/index.d.ts +2 -2
  23. package/dist/device-cert/index.js +1 -1
  24. package/dist/events/sse.d.ts +22 -2
  25. package/dist/events/sse.js +20 -11
  26. package/dist/http-client/http-types.d.ts +18 -0
  27. package/dist/http-client/web-client.js +21 -1
  28. package/dist/index.browser.mjs +2 -2
  29. package/dist/index.browser.mjs.map +4 -4
  30. package/dist/index.cjs +1666 -365
  31. package/dist/index.cjs.map +4 -4
  32. package/dist/index.d.ts +9 -4
  33. package/dist/index.js +16 -2
  34. package/dist/index.mjs +1666 -365
  35. package/dist/index.mjs.map +4 -4
  36. package/dist/login/login.d.ts +11 -1
  37. package/dist/login/login.js +5 -4
  38. package/dist/login/session.d.ts +21 -5
  39. package/dist/login/session.js +7 -15
  40. package/dist/namespace-op/namespace-op.d.ts +14 -2
  41. package/dist/namespace-op/namespace-op.js +9 -5
  42. package/dist/relay/index.d.ts +2 -2
  43. package/dist/relay/index.js +1 -1
  44. package/dist/relay/nonce-source.d.ts +135 -0
  45. package/dist/relay/nonce-source.js +187 -19
  46. package/dist/relay/relay-client.d.ts +60 -5
  47. package/dist/relay/relay-client.js +84 -4
  48. package/dist/relay-attestation/attest-node-key.d.ts +66 -0
  49. package/dist/relay-attestation/attest-node-key.js +110 -0
  50. package/dist/relay-attestation/index.d.ts +1 -0
  51. package/dist/relay-attestation/index.js +1 -0
  52. package/dist/request-proof/authorizer.d.ts +48 -0
  53. package/dist/request-proof/authorizer.js +65 -0
  54. package/dist/request-proof/index.d.ts +4 -0
  55. package/dist/request-proof/index.js +2 -0
  56. package/dist/request-proof/request-proof.d.ts +56 -0
  57. package/dist/request-proof/request-proof.js +111 -0
  58. package/dist/rpc/index.d.ts +21 -0
  59. package/dist/rpc/index.js +20 -0
  60. package/dist/transport/index.d.ts +6 -0
  61. package/dist/transport/index.js +3 -0
  62. package/dist/transport/mero-client.d.ts +286 -0
  63. package/dist/transport/mero-client.js +379 -0
  64. package/dist/transport/relay-observer.d.ts +152 -0
  65. package/dist/transport/relay-observer.js +173 -0
  66. package/dist/transport/relay-transport.d.ts +73 -0
  67. package/dist/transport/relay-transport.js +94 -0
  68. package/dist/transport/types.d.ts +100 -0
  69. package/dist/transport/types.js +32 -0
  70. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -170,8 +170,27 @@ var WebHttpClient = class {
170
170
  } else {
171
171
  headersObj = headers;
172
172
  }
173
+ const method = init?.method || "GET";
174
+ if (this.transport.authorizeRequest) {
175
+ const body = init?.body;
176
+ if (body !== void 0 && typeof body !== "string" && !(body instanceof Uint8Array)) {
177
+ throw new Error(
178
+ "this transport authorizes each request against its body, so the body must be a string or Uint8Array \u2014 a stream cannot be signed, because the signature would have to read bytes the request then cannot send."
179
+ );
180
+ }
181
+ Object.assign(
182
+ headersObj,
183
+ await this.transport.authorizeRequest({
184
+ method,
185
+ path: new URL(url).pathname,
186
+ // Narrowed by the guard above; `BodyInit` is wider than what can be
187
+ // signed.
188
+ body
189
+ })
190
+ );
191
+ }
173
192
  const requestInit = {
174
- method: init?.method || "GET",
193
+ method,
175
194
  headers: headersObj
176
195
  };
177
196
  const isStreamBody = init?.body instanceof ReadableStream || typeof init?.body === "object" && init?.body !== null && "getReader" in init.body && !(init.body instanceof Blob);
@@ -931,6 +950,50 @@ async function redeemInvitation(parsed, redeemer) {
931
950
  }
932
951
 
933
952
  // src/admin-api/admin-client.ts
953
+ var WarrantNonceRouteUnavailableError = class extends Error {
954
+ constructor(path, cause) {
955
+ super(
956
+ `this node does not serve warrant-nonce discovery (404 on ${path}); it predates the route, so a client must fall back to its own nonce counter`
957
+ );
958
+ this.path = path;
959
+ this.cause = cause;
960
+ this.name = "WarrantNonceRouteUnavailableError";
961
+ }
962
+ };
963
+ function u64FromBody(body, parsed, field) {
964
+ const value = parsed[field];
965
+ if (value === void 0 || value === null) return void 0;
966
+ const digits = new RegExp(`"${field}"\\s*:\\s*(\\d+)`).exec(body);
967
+ if (digits) return BigInt(digits[1]);
968
+ if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
969
+ throw new Error(`warrant-nonce response field \`${field}\` is not a u64: ${JSON.stringify(value)}`);
970
+ }
971
+ function parseWarrantNonce(body) {
972
+ const parsed = JSON.parse(body);
973
+ const data = parsed?.data;
974
+ if (!data || typeof data !== "object") {
975
+ throw new Error(`warrant-nonce response had no \`data\`: ${body.slice(0, 200)}`);
976
+ }
977
+ const common = {
978
+ contextId: String(data.contextId ?? ""),
979
+ authorDeviceKey: String(data.authorDeviceKey ?? ""),
980
+ seen: data.seen === true,
981
+ highWaterNonce: u64FromBody(body, data, "highWaterNonce"),
982
+ // Not defaulted: a node that stopped sending it is a node whose window this
983
+ // client would be guessing at, and the window is not a nonce input anyway.
984
+ windowWidth: u64FromBody(body, data, "windowWidth") ?? 0n
985
+ };
986
+ const nextNonce = u64FromBody(body, data, "nextNonce");
987
+ if (nextNonce === void 0) {
988
+ return {
989
+ ...common,
990
+ kind: "exhausted",
991
+ seen: true,
992
+ highWaterNonce: common.highWaterNonce ?? 0xffffffffffffffffn
993
+ };
994
+ }
995
+ return { ...common, kind: "open", nextNonce };
996
+ }
934
997
  function unwrap(response) {
935
998
  return response?.data;
936
999
  }
@@ -1731,6 +1794,13 @@ var AdminApiClient = class {
1731
1794
  * `namespaceId` names where to publish from, not the extent of the revocation:
1732
1795
  * a device belongs to the account, so every namespace holding a binding for it
1733
1796
  * is withdrawn from and reported back.
1797
+ *
1798
+ * **Revocation is forward-only.** It withdraws the device's authority from
1799
+ * here on and does NOT retract past authorship: ops the device already signed
1800
+ * keep verifying and stay in the DAG, because the certificate that authorised
1801
+ * them was valid when the signature was made. Nothing in this system can
1802
+ * un-author what a device wrote, so a UI built on this call must not offer
1803
+ * that - "this device can no longer act" is the only claim it supports.
1734
1804
  */
1735
1805
  async revokeAccountDevice(namespaceId, request) {
1736
1806
  return unwrap(
@@ -1773,6 +1843,138 @@ var AdminApiClient = class {
1773
1843
  }
1774
1844
  return response;
1775
1845
  }
1846
+ /**
1847
+ * Account -> devices for the members of a group, as its live bindings record
1848
+ * them.
1849
+ *
1850
+ * The join between a group member listing, which names accounts, and a
1851
+ * context's identities, which name bare signing keys - both 64 hex, and
1852
+ * nothing but the source tells them apart. Batch rather than per-account
1853
+ * because the key -> account direction gets asked about arbitrary authors,
1854
+ * which a per-account route would answer in N calls.
1855
+ *
1856
+ * What comes back is **caller-scoped by the node**: an admin of the group sees
1857
+ * every member's devices, a plain member only its own entry. A single-entry
1858
+ * answer is therefore a statement about this caller's role, not about the
1859
+ * group's size.
1860
+ *
1861
+ * Only live bindings appear, so a revoked device is absent here - use
1862
+ * {@link listAccountDevices} for the account's own devices, which keeps a
1863
+ * revoked one with `revoked: true`. Absence here never means the device never
1864
+ * acted: revocation is forward-only and its past ops remain in the DAG.
1865
+ *
1866
+ * Wrapped in `members` on the wire rather than `data`, unwrapped here for the
1867
+ * same reason {@link listAccountDevices} unwraps `devices`.
1868
+ */
1869
+ async listGroupMemberDevices(groupId, options) {
1870
+ const params = new URLSearchParams();
1871
+ if (options?.offset !== void 0) params.set("offset", String(options.offset));
1872
+ if (options?.limit !== void 0) params.set("limit", String(options.limit));
1873
+ const query = params.toString();
1874
+ const base = `/admin-api/groups/${encodeURIComponent(groupId)}/member-devices`;
1875
+ const response = await this.httpClient.get(
1876
+ query ? `${base}?${query}` : base
1877
+ );
1878
+ return response.members;
1879
+ }
1880
+ /**
1881
+ * Where `authorDeviceKey` stands in its warrant-nonce sequence in `contextId`.
1882
+ *
1883
+ * # What this is for
1884
+ *
1885
+ * The nonce is the one input to a warrant that is stateful on the *client*.
1886
+ * Everything else an author needs it either holds (its keys, the method, the
1887
+ * args) or can read from the relay ({@link RelayClient.describe} gives the
1888
+ * executor account and the grant). So an author that has lost its counter —
1889
+ * a browser keyholder in partitioned or periodically cleared storage, which
1890
+ * is its normal lifecycle rather than a fault — otherwise has exactly one
1891
+ * strategy: guess upward and burn a refused round trip per wrong guess, with
1892
+ * each refusal indistinguishable at the API from a genuinely spent nonce.
1893
+ *
1894
+ * Read {@link WarrantNonceOpen.nextNonce} and mint there. If you still hold
1895
+ * your own counter, mint at `max(ownNext, nextNonce)`: nonce state folds per
1896
+ * peer, so a node can only be BEHIND a client whose warrants it has not
1897
+ * applied — never ahead of the truth. An author spread across several relays
1898
+ * asks each and takes the highest answer. {@link createRecoveringNonceSource}
1899
+ * does all of that.
1900
+ *
1901
+ * Purely a read. It spends nothing and advances nothing, so it may be called
1902
+ * as often as you like.
1903
+ *
1904
+ * # Forward compatibility — this route is NEW
1905
+ *
1906
+ * It is not on core `master` yet, so a current node answers **404**, which
1907
+ * this method surfaces as {@link WarrantNonceRouteUnavailableError} rather
1908
+ * than as an opaque {@link HTTPError}. That distinction matters because the
1909
+ * handler itself never 404s: a device with no row is a `200` with
1910
+ * `seen: false`, and a malformed id is a `400`. So a 404 here means the node
1911
+ * does not serve the route at all — an older build, or a deployment that has
1912
+ * not re-mounted it — and the answer is to fall back to a local counter, not
1913
+ * to retry.
1914
+ *
1915
+ * # Authentication
1916
+ *
1917
+ * On the PROTECTED admin router, unlike the two delegated-execution routes it
1918
+ * sits beside: it takes another principal's device key as input and reports
1919
+ * that principal's activity. A `--public-intents` deployment does not serve
1920
+ * this anonymously, so an admin credential is required.
1921
+ *
1922
+ * @param authorDeviceKey the device's signing key, base58 (43-44 chars) or
1923
+ * hex (exactly 64). The node accepts either; the two never collide.
1924
+ */
1925
+ async getWarrantNonce(contextId, authorDeviceKey) {
1926
+ const path = `/admin-api/contexts/${encodeURIComponent(contextId)}/warrant-nonce/${encodeURIComponent(authorDeviceKey)}`;
1927
+ let raw;
1928
+ try {
1929
+ raw = await this.httpClient.get(path, { parse: "text" });
1930
+ } catch (error) {
1931
+ if (error instanceof HTTPError && error.status === 404) {
1932
+ throw new WarrantNonceRouteUnavailableError(path, error);
1933
+ }
1934
+ throw error;
1935
+ }
1936
+ return parseWarrantNonce(raw);
1937
+ }
1938
+ /**
1939
+ * The same answer, asked for the way a delegated client has to ask.
1940
+ *
1941
+ * {@link getWarrantNonce} is on the protected admin router, so a keyholder
1942
+ * writing through a relay — the client this whole facility exists for — is
1943
+ * refused it: measured `403` for a session carrying exactly
1944
+ * `context:intent`, `context:query`, `context:subscribe`. That client has no
1945
+ * admin credential and cannot be given one without defeating the point of the
1946
+ * delegated surface.
1947
+ *
1948
+ * What it does have is the credential proving the device is its own, which it
1949
+ * already sends on every write. Presenting it here makes the request
1950
+ * self-scoped: you learn a device's position only by proving, at call time,
1951
+ * that the device is yours. So there is no oracle to protect against, and no
1952
+ * reason for an admin gate.
1953
+ *
1954
+ * The device key is NOT a parameter. It is read from the verified
1955
+ * certificate server-side, which is the only reading that cannot disagree
1956
+ * with the signature — a key supplied alongside would be a second spelling of
1957
+ * the same fact, and two spellings can differ.
1958
+ *
1959
+ * Returns the identical shape {@link getWarrantNonce} returns, so a caller can
1960
+ * swap one for the other without touching what it does with the answer.
1961
+ *
1962
+ * @param authorProof hex-encoded borsh `AccountProof<DeviceCert>` — the same
1963
+ * value passed as `authorProof` to the relay client.
1964
+ */
1965
+ async getWarrantNonceAsAuthor(contextId, authorProof) {
1966
+ const path = `/admin-api/contexts/${encodeURIComponent(contextId)}/warrant-nonce`;
1967
+ let raw;
1968
+ try {
1969
+ raw = await this.httpClient.post(path, { authorProof }, { parse: "text" });
1970
+ } catch (error) {
1971
+ if (error instanceof HTTPError && error.status === 404) {
1972
+ throw new WarrantNonceRouteUnavailableError(path, error);
1973
+ }
1974
+ throw error;
1975
+ }
1976
+ return parseWarrantNonce(raw);
1977
+ }
1776
1978
  /**
1777
1979
  * Member accounts in the group and the devices bound to each. An admin node
1778
1980
  * sees every account; a plain member only its own. `403` if this node is not a
@@ -2283,6 +2485,16 @@ async function importSigningKey(secretHex) {
2283
2485
  );
2284
2486
  }
2285
2487
  }
2488
+ async function verifySignature(publicKey, signature, payload) {
2489
+ const key = await crypto.subtle.importKey(
2490
+ "raw",
2491
+ fromHex(publicKey, "publicKey", 32),
2492
+ { name: "Ed25519" },
2493
+ false,
2494
+ ["verify"]
2495
+ );
2496
+ return crypto.subtle.verify({ name: "Ed25519" }, key, signature, payload);
2497
+ }
2286
2498
  async function derivePublicKey(secretHex) {
2287
2499
  const seed = fromHex(secretHex, "deviceSecret", 32);
2288
2500
  const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
@@ -2406,6 +2618,15 @@ async function jsonRpcCall(httpClient, method, params) {
2406
2618
  }
2407
2619
  var RpcClient = class {
2408
2620
  constructor(opts) {
2621
+ /**
2622
+ * Which write path this is. Present so `RpcClient` satisfies
2623
+ * `ExecuteTransport` as-is — the transport abstraction adopted *this* class's
2624
+ * `execute` shape as the canonical one, so the node side is the interface
2625
+ * rather than an adapter onto it.
2626
+ */
2627
+ this.kind = "node";
2628
+ /** A node serves `/sse` and `/ws` to the credential it issued. */
2629
+ this.canSubscribe = true;
2409
2630
  this.httpClient = opts.httpClient;
2410
2631
  }
2411
2632
  async execute(params) {
@@ -2419,6 +2640,17 @@ var RpcClient = class {
2419
2640
  }
2420
2641
  return result;
2421
2642
  }
2643
+ /**
2644
+ * The same call, in the shape the transport abstraction shares with the
2645
+ * relay.
2646
+ *
2647
+ * `rootHash` is left absent rather than synthesized: JSON-RPC `execute` does
2648
+ * not report the context's scope root, and inventing a value — or a `''` —
2649
+ * would make "the node does not tell us" indistinguishable from an answer.
2650
+ */
2651
+ async executeWithMetadata(params) {
2652
+ return { returns: await this.execute(params), transport: "node" };
2653
+ }
2422
2654
  /**
2423
2655
  * One-tap owner-driven convert: re-signs the caller's identity-gated entries
2424
2656
  * to the current schema. The export converts all of the caller's
@@ -2464,7 +2696,14 @@ var SseClient = class {
2464
2696
  this.closed = false;
2465
2697
  this.listeners = { connect: [], event: [], error: [] };
2466
2698
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
2467
- this.getAuthToken = opts.getAuthToken;
2699
+ if (opts.authorize) {
2700
+ this.authorize = opts.authorize;
2701
+ } else if (opts.getAuthToken) {
2702
+ const getAuthToken = opts.getAuthToken;
2703
+ this.authorize = async () => ({ Authorization: `Bearer ${await getAuthToken()}` });
2704
+ } else {
2705
+ throw new Error("SseClient needs either `getAuthToken` or `authorize`");
2706
+ }
2468
2707
  this.reconnectDelayMs = opts.reconnectDelayMs ?? 3e3;
2469
2708
  this.fetchImpl = (url, init) => opts.fetch ? opts.fetch(url, init) : globalThis.fetch(url, init);
2470
2709
  }
@@ -2531,11 +2770,10 @@ var SseClient = class {
2531
2770
  this.closed = false;
2532
2771
  this.abortController = new AbortController();
2533
2772
  try {
2534
- const token = await this.getAuthToken();
2535
2773
  const url = `${this.baseUrl}/sse`;
2536
2774
  const response = await this.fetchImpl(url, {
2537
2775
  headers: {
2538
- "Authorization": `Bearer ${token}`,
2776
+ ...await this.authorize({ method: "GET", path: new URL(url).pathname }),
2539
2777
  "Accept": "text/event-stream"
2540
2778
  },
2541
2779
  signal: this.abortController.signal
@@ -2688,21 +2926,18 @@ var SseClient = class {
2688
2926
  }
2689
2927
  async sendSubscription(method, ids) {
2690
2928
  try {
2691
- const token = await this.getAuthToken();
2692
2929
  const params = {};
2693
2930
  if (ids.contextIds && ids.contextIds.length > 0) params.contextIds = ids.contextIds;
2694
2931
  if (ids.groupIds && ids.groupIds.length > 0) params.groupIds = ids.groupIds;
2695
- const response = await this.fetchImpl(`${this.baseUrl}/sse/subscription`, {
2932
+ const url = `${this.baseUrl}/sse/subscription`;
2933
+ const body = JSON.stringify({ id: this.sessionId, method, params });
2934
+ const response = await this.fetchImpl(url, {
2696
2935
  method: "POST",
2697
2936
  headers: {
2698
- "Authorization": `Bearer ${token}`,
2937
+ ...await this.authorize({ method: "POST", path: new URL(url).pathname, body }),
2699
2938
  "Content-Type": "application/json"
2700
2939
  },
2701
- body: JSON.stringify({
2702
- id: this.sessionId,
2703
- method,
2704
- params
2705
- })
2940
+ body
2706
2941
  });
2707
2942
  if (!response.ok) {
2708
2943
  const terminal = isTerminalAuthFailure(response);
@@ -3021,11 +3256,123 @@ var EphemeralClient = class {
3021
3256
  }
3022
3257
  };
3023
3258
 
3259
+ // src/signer/signer.ts
3260
+ var PKCS8_ED25519_PREFIX2 = new Uint8Array([
3261
+ 48,
3262
+ 46,
3263
+ 2,
3264
+ 1,
3265
+ 0,
3266
+ 48,
3267
+ 5,
3268
+ 6,
3269
+ 3,
3270
+ 43,
3271
+ 101,
3272
+ 112,
3273
+ 4,
3274
+ 34,
3275
+ 4,
3276
+ 32
3277
+ ]);
3278
+ function unsupported(cause) {
3279
+ const detail = cause instanceof Error ? `: ${cause.message}` : "";
3280
+ return new Error(
3281
+ "this runtime has no WebCrypto Ed25519, so nothing can be signed here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it" + detail
3282
+ );
3283
+ }
3284
+ async function signerFromSecret(secret, label2 = "secret") {
3285
+ const seed = fromHex(secret, label2, 32);
3286
+ const pkcs8 = concat(PKCS8_ED25519_PREFIX2, seed);
3287
+ let privateKey;
3288
+ let publicKey;
3289
+ try {
3290
+ const exportable = await crypto.subtle.importKey(
3291
+ "pkcs8",
3292
+ pkcs8,
3293
+ { name: "Ed25519" },
3294
+ true,
3295
+ ["sign"]
3296
+ );
3297
+ const jwk = await crypto.subtle.exportKey("jwk", exportable);
3298
+ publicKey = hex(
3299
+ Uint8Array.from(
3300
+ atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")),
3301
+ (c) => c.charCodeAt(0)
3302
+ )
3303
+ );
3304
+ privateKey = await crypto.subtle.importKey(
3305
+ "pkcs8",
3306
+ pkcs8,
3307
+ { name: "Ed25519" },
3308
+ false,
3309
+ ["sign"]
3310
+ );
3311
+ } catch (cause) {
3312
+ throw unsupported(cause);
3313
+ }
3314
+ return fromKeyAndPublic(privateKey, publicKey);
3315
+ }
3316
+ async function signerFromCryptoKey(privateKey, publicKey) {
3317
+ if (privateKey.type !== "private") {
3318
+ throw new Error(
3319
+ `signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`
3320
+ );
3321
+ }
3322
+ if (!privateKey.usages.includes("sign")) {
3323
+ throw new Error(
3324
+ "this CryptoKey cannot sign: generate it with usages ['sign', 'verify']"
3325
+ );
3326
+ }
3327
+ let publicHex;
3328
+ if (typeof publicKey === "string") {
3329
+ fromHex(publicKey, "publicKey", 32);
3330
+ publicHex = publicKey.trim().toLowerCase();
3331
+ } else {
3332
+ try {
3333
+ publicHex = hex(
3334
+ new Uint8Array(await crypto.subtle.exportKey("raw", publicKey))
3335
+ );
3336
+ } catch (cause) {
3337
+ const detail = cause instanceof Error ? `: ${cause.message}` : "";
3338
+ throw new Error(
3339
+ "could not read the public half of this key pair; pass the public key as 64 hex instead" + detail
3340
+ );
3341
+ }
3342
+ }
3343
+ return fromKeyAndPublic(privateKey, publicHex);
3344
+ }
3345
+ function fromKeyAndPublic(privateKey, publicKey) {
3346
+ return {
3347
+ publicKey,
3348
+ async sign(payload) {
3349
+ try {
3350
+ return new Uint8Array(
3351
+ await crypto.subtle.sign({ name: "Ed25519" }, privateKey, payload)
3352
+ );
3353
+ } catch (cause) {
3354
+ throw unsupported(cause);
3355
+ }
3356
+ }
3357
+ };
3358
+ }
3359
+ async function resolveSigner(secret, signer, label2) {
3360
+ if (signer && secret !== void 0) {
3361
+ throw new Error(
3362
+ `pass either ${label2} or signer, not both \u2014 they may name different keys`
3363
+ );
3364
+ }
3365
+ if (signer) return signer;
3366
+ if (secret !== void 0) return signerFromSecret(secret, label2);
3367
+ throw new Error(`${label2} or signer is required`);
3368
+ }
3369
+
3024
3370
  // src/device-cert/device-cert.ts
3025
3371
  var CERT_DOMAIN = new TextEncoder().encode("calimero.device.cert.v1");
3026
3372
  var ACCOUNT_ID_DOMAIN = new TextEncoder().encode("calimero.account.genesis.v1");
3027
3373
  var DEVICE_ID_DOMAIN = new TextEncoder().encode("calimero.device.id.v1");
3028
3374
  var ACCOUNT_GENESIS_VERSION = 2;
3375
+ var ACCOUNT_PROOF_BYTES = 237;
3029
3376
  async function mintDeviceId(account, nonce2) {
3030
3377
  if (nonce2.length !== 16) {
3031
3378
  throw new Error(`nonce must be 16 bytes, got ${nonce2.length}`);
@@ -3058,12 +3405,9 @@ function accountProofBytes(input) {
3058
3405
  );
3059
3406
  }
3060
3407
  async function signDeviceCert(input) {
3061
- const rootPublicKey = await derivePublicKey(input.rootSecret);
3062
- const account = hex(
3063
- await domainHash(ACCOUNT_ID_DOMAIN, [
3064
- concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey)
3065
- ])
3066
- );
3408
+ const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
3409
+ const rootPublicKey = fromHex(signer.publicKey, "signer.publicKey", 32);
3410
+ const account = await accountForRootPublicKey(signer.publicKey);
3067
3411
  const keyEpoch = 0;
3068
3412
  const payload = await deviceCertPayload({
3069
3413
  account,
@@ -3073,10 +3417,7 @@ async function signDeviceCert(input) {
3073
3417
  keyEpoch,
3074
3418
  deviceEpoch: input.deviceEpoch
3075
3419
  });
3076
- const key = await importSigningKey(input.rootSecret);
3077
- const signature = new Uint8Array(
3078
- await crypto.subtle.sign("Ed25519", key, payload)
3079
- );
3420
+ const signature = await signer.sign(payload);
3080
3421
  const credential = accountProofBytes({
3081
3422
  rootPublicKey,
3082
3423
  account,
@@ -3089,11 +3430,67 @@ async function signDeviceCert(input) {
3089
3430
  });
3090
3431
  return hex(credential);
3091
3432
  }
3433
+ function parseDeviceCredential(credential) {
3434
+ const bytes = fromHex(
3435
+ credential,
3436
+ "credential",
3437
+ // Fixed-width with an empty chain: 1 + 32 + 4 + 32*4 + 4 + 4 + 64.
3438
+ ACCOUNT_PROOF_BYTES
3439
+ );
3440
+ if (bytes[0] !== ACCOUNT_GENESIS_VERSION) {
3441
+ throw new Error(
3442
+ `this credential has account genesis version ${bytes[0]}, not ${ACCOUNT_GENESIS_VERSION} \u2014 it names a different account than its root key derives here, and core would refuse it`
3443
+ );
3444
+ }
3445
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3446
+ const chainLength = view.getUint32(33, true);
3447
+ if (chainLength !== 0) {
3448
+ throw new Error(
3449
+ `this credential carries a ${chainLength}-handoff root chain, which this SDK cannot verify yet \u2014 the signing root is not the genesis root, so checking it means walking the chain`
3450
+ );
3451
+ }
3452
+ const at = (offset, length) => hex(bytes.subarray(offset, offset + length));
3453
+ return {
3454
+ rootPublicKey: at(1, 32),
3455
+ account: at(37, 32),
3456
+ device: at(69, 32),
3457
+ signPublicKey: at(101, 32),
3458
+ kemPublicKey: at(133, 32),
3459
+ keyEpoch: view.getUint32(165, true),
3460
+ deviceEpoch: view.getUint32(169, true),
3461
+ signature: at(173, 64)
3462
+ };
3463
+ }
3464
+ async function verifyDeviceCredential(credential) {
3465
+ const parsed = parseDeviceCredential(credential);
3466
+ const derived = await accountForRootPublicKey(parsed.rootPublicKey);
3467
+ if (derived !== parsed.account) {
3468
+ throw new Error(
3469
+ `this credential names account ${parsed.account}, but its root key derives ${derived} \u2014 the certificate was re-pointed at another account`
3470
+ );
3471
+ }
3472
+ const signed = await verifySignature(
3473
+ parsed.rootPublicKey,
3474
+ fromHex(parsed.signature, "signature", 64),
3475
+ await deviceCertPayload(parsed)
3476
+ );
3477
+ if (!signed) {
3478
+ throw new Error(
3479
+ `the root key of account ${parsed.account} did not sign this certificate`
3480
+ );
3481
+ }
3482
+ return parsed;
3483
+ }
3092
3484
  async function accountForRoot(rootSecret) {
3093
- const rootPublicKey = await derivePublicKey(rootSecret);
3485
+ return accountForRootPublicKey(hex(await derivePublicKey(rootSecret)));
3486
+ }
3487
+ async function accountForRootPublicKey(rootPublicKey) {
3094
3488
  return hex(
3095
3489
  await domainHash(ACCOUNT_ID_DOMAIN, [
3096
- concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey)
3490
+ concat(
3491
+ new Uint8Array([ACCOUNT_GENESIS_VERSION]),
3492
+ fromHex(rootPublicKey, "rootPublicKey", 32)
3493
+ )
3097
3494
  ])
3098
3495
  );
3099
3496
  }
@@ -5176,27 +5573,50 @@ async function accountRootFromSecret(secret) {
5176
5573
  accountId: await accountForRoot(secret)
5177
5574
  };
5178
5575
  }
5576
+ async function createAccountRootSigner() {
5577
+ const entropy = crypto.getRandomValues(new Uint8Array(ENTROPY_BITS / 8));
5578
+ const phrase = await encodePhrase(entropy);
5579
+ return { ...await rootSignerFromEntropy(entropy), phrase };
5580
+ }
5581
+ async function accountRootSignerFromPhrase(phrase) {
5582
+ return rootSignerFromEntropy(await decodePhrase(normalisePhrase(phrase)));
5583
+ }
5584
+ async function rootSignerFromEntropy(entropy) {
5585
+ const signer = await signerFromSecret(hex(entropy), "rootSecret");
5586
+ entropy.fill(0);
5587
+ return {
5588
+ publicKey: signer.publicKey,
5589
+ accountId: await accountForRootPublicKey(signer.publicKey),
5590
+ signer
5591
+ };
5592
+ }
5593
+ async function resolveRoot(source) {
5594
+ const signer = typeof source === "string" ? await signerFromSecret(source, "rootSecret") : source;
5595
+ return identify(signer);
5596
+ }
5597
+ async function resolveRootPair(rootSecret, signer) {
5598
+ return identify(await resolveSigner(rootSecret, signer, "rootSecret"));
5599
+ }
5600
+ async function identify(signer) {
5601
+ return {
5602
+ signer,
5603
+ publicKey: signer.publicKey,
5604
+ accountId: await accountForRootPublicKey(signer.publicKey)
5605
+ };
5606
+ }
5179
5607
  async function signAccountLink(input) {
5180
5608
  if (!input.nonce) throw new Error("nonce is required to sign an account link");
5181
- const key = await importSigningKey(input.rootSecret);
5182
- const signature = new Uint8Array(
5183
- await crypto.subtle.sign(
5184
- "Ed25519",
5185
- key,
5186
- concat(ACCOUNT_LINK_DOMAIN, new TextEncoder().encode(input.nonce))
5187
- )
5609
+ const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
5610
+ const signature = await signer.sign(
5611
+ concat(ACCOUNT_LINK_DOMAIN, new TextEncoder().encode(input.nonce))
5188
5612
  );
5189
5613
  return base64(signature);
5190
5614
  }
5191
5615
  async function signAccountLogin(input) {
5192
5616
  if (!input.nonce) throw new Error("nonce is required to sign an account login");
5193
- const key = await importSigningKey(input.rootSecret);
5194
- const signature = new Uint8Array(
5195
- await crypto.subtle.sign(
5196
- "Ed25519",
5197
- key,
5198
- concat(ACCOUNT_LOGIN_DOMAIN, new TextEncoder().encode(input.nonce))
5199
- )
5617
+ const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
5618
+ const signature = await signer.sign(
5619
+ concat(ACCOUNT_LOGIN_DOMAIN, new TextEncoder().encode(input.nonce))
5200
5620
  );
5201
5621
  return base64(signature);
5202
5622
  }
@@ -5290,7 +5710,7 @@ function base642(bytes) {
5290
5710
  }
5291
5711
 
5292
5712
  // src/cloud/cloud-client.ts
5293
- var DEFAULT_CLOUD_BASE_URL = "https://cloud.calimero.network";
5713
+ var DEFAULT_CLOUD_BASE_URL = "https://manager.cloud.calimero.network";
5294
5714
  function canExecuteOf(row, legacy) {
5295
5715
  return typeof row.can_execute === "boolean" ? row.can_execute : legacy;
5296
5716
  }
@@ -5354,8 +5774,10 @@ var CloudClient = class {
5354
5774
  * account behind those reads was claimed by its owner. The claim is recorded
5355
5775
  * even when the session is refused.
5356
5776
  *
5357
- * @param rootSecret the account root's signing secret, 64 hex. It never
5358
- * leaves this process — only a signature over the cloud's nonce travels.
5777
+ * @param root the account root: its 64-hex secret, or a `Signer` over a root
5778
+ * this process may use and cannot read — the form a phrase-held root in a
5779
+ * browser takes. Either way nothing but a signature over the cloud's nonce
5780
+ * travels.
5359
5781
  *
5360
5782
  * A `403` means either the signature did not verify, or the account is not
5361
5783
  * linked to a cloud login: ownership alone says who you are, and the link
@@ -5363,13 +5785,13 @@ var CloudClient = class {
5363
5785
  * on the proof alone would authenticate perfectly and authorize nothing. The
5364
5786
  * ownership claim is recorded either way, so linking later needs no re-proof.
5365
5787
  */
5366
- async signInWithAccount(rootSecret) {
5367
- const root = await accountRootFromSecret(rootSecret);
5788
+ async signInWithAccount(root) {
5789
+ const { signer, publicKey } = await resolveRoot(root);
5368
5790
  const { nonce: nonce2 } = await this.getAccountLoginChallenge();
5369
5791
  return this.submitAccountLogin({
5370
- rootPublicKey: root.publicKey,
5792
+ rootPublicKey: publicKey,
5371
5793
  nonce: nonce2,
5372
- signature: await signAccountLogin({ rootSecret, nonce: nonce2 })
5794
+ signature: await signAccountLogin({ signer, nonce: nonce2 })
5373
5795
  });
5374
5796
  }
5375
5797
  /**
@@ -5466,6 +5888,7 @@ var CloudClient = class {
5466
5888
  peerId: String(row.peer_id ?? ""),
5467
5889
  relayUrl,
5468
5890
  executorAccount,
5891
+ nodeKey: row.node_key ?? row.node_public_key ?? null,
5469
5892
  status: String(row.status ?? ""),
5470
5893
  authorshipReady,
5471
5894
  teeRole: teeRoleOf(row),
@@ -5808,22 +6231,23 @@ var CloudClient = class {
5808
6231
  * an intent; the cloud needs it to know whose plan a hosted node is spending,
5809
6232
  * and to sign that account back in later.
5810
6233
  *
5811
- * @param rootSecret the account root's signing secret, 64 hex — from
5812
- * {@link generateAccountRoot}, from a restored recovery phrase, or from a
5813
- * desktop app or hardware key that already holds one.
6234
+ * @param root the account root: its 64-hex secret — from
6235
+ * {@link generateAccountRoot}, a restored recovery phrase, or a desktop app
6236
+ * or hardware key that already holds one — or a `Signer` over a root this
6237
+ * process cannot read, which is what a phrase-held browser root is.
5814
6238
  *
5815
6239
  * Idempotent for the same login. An account already linked to a *different*
5816
6240
  * cloud login is a `409`; a replayed or expired challenge is a `403`; and
5817
6241
  * exceeding the plan's account limit is a `402`.
5818
6242
  */
5819
- async linkAccount(rootSecret) {
5820
- const root = await accountRootFromSecret(rootSecret);
6243
+ async linkAccount(root) {
6244
+ const { signer, publicKey, accountId } = await resolveRoot(root);
5821
6245
  const { nonce: nonce2 } = await this.getAccountLinkChallenge();
5822
6246
  return this.submitAccountLink({
5823
- accountId: root.accountId,
5824
- rootPublicKey: root.publicKey,
6247
+ accountId,
6248
+ rootPublicKey: publicKey,
5825
6249
  nonce: nonce2,
5826
- signature: await signAccountLink({ rootSecret, nonce: nonce2 })
6250
+ signature: await signAccountLink({ signer, nonce: nonce2 })
5827
6251
  });
5828
6252
  }
5829
6253
  /**
@@ -5943,16 +6367,19 @@ var CloudClient = class {
5943
6367
  * login; `402` that the plan is full.
5944
6368
  */
5945
6369
  async linkAccountWithGrant(options) {
5946
- const root = await accountRootFromSecret(options.rootSecret);
6370
+ const { signer, publicKey, accountId } = await resolveRootPair(
6371
+ options.rootSecret,
6372
+ options.signer
6373
+ );
5947
6374
  const body = await this.request(
5948
6375
  "POST",
5949
6376
  "/api/cloud/accounts/link",
5950
6377
  {
5951
6378
  body: {
5952
6379
  grant: options.grant,
5953
- root_public_key: root.publicKey,
6380
+ root_public_key: publicKey,
5954
6381
  signature: await signAccountLink({
5955
- rootSecret: options.rootSecret,
6382
+ signer,
5956
6383
  nonce: options.grant
5957
6384
  })
5958
6385
  },
@@ -5960,7 +6387,7 @@ var CloudClient = class {
5960
6387
  }
5961
6388
  );
5962
6389
  return {
5963
- accountId: String(body.account_id ?? root.accountId),
6390
+ accountId: String(body.account_id ?? accountId),
5964
6391
  linkedAt: body.linked_at ?? null,
5965
6392
  alreadyLinked: body.already_linked === true
5966
6393
  };
@@ -6484,117 +6911,6 @@ var LocalStorageTokenStore = class {
6484
6911
  }
6485
6912
  };
6486
6913
 
6487
- // src/signer/signer.ts
6488
- var PKCS8_ED25519_PREFIX2 = new Uint8Array([
6489
- 48,
6490
- 46,
6491
- 2,
6492
- 1,
6493
- 0,
6494
- 48,
6495
- 5,
6496
- 6,
6497
- 3,
6498
- 43,
6499
- 101,
6500
- 112,
6501
- 4,
6502
- 34,
6503
- 4,
6504
- 32
6505
- ]);
6506
- function unsupported(cause) {
6507
- const detail = cause instanceof Error ? `: ${cause.message}` : "";
6508
- return new Error(
6509
- "this runtime has no WebCrypto Ed25519, so nothing can be signed here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it" + detail
6510
- );
6511
- }
6512
- async function signerFromSecret(secret, label2 = "secret") {
6513
- const seed = fromHex(secret, label2, 32);
6514
- const pkcs8 = concat(PKCS8_ED25519_PREFIX2, seed);
6515
- let privateKey;
6516
- let publicKey;
6517
- try {
6518
- const exportable = await crypto.subtle.importKey(
6519
- "pkcs8",
6520
- pkcs8,
6521
- { name: "Ed25519" },
6522
- true,
6523
- ["sign"]
6524
- );
6525
- const jwk = await crypto.subtle.exportKey("jwk", exportable);
6526
- publicKey = hex(
6527
- Uint8Array.from(
6528
- atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")),
6529
- (c) => c.charCodeAt(0)
6530
- )
6531
- );
6532
- privateKey = await crypto.subtle.importKey(
6533
- "pkcs8",
6534
- pkcs8,
6535
- { name: "Ed25519" },
6536
- false,
6537
- ["sign"]
6538
- );
6539
- } catch (cause) {
6540
- throw unsupported(cause);
6541
- }
6542
- return fromKeyAndPublic(privateKey, publicKey);
6543
- }
6544
- async function signerFromCryptoKey(privateKey, publicKey) {
6545
- if (privateKey.type !== "private") {
6546
- throw new Error(
6547
- `signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`
6548
- );
6549
- }
6550
- if (!privateKey.usages.includes("sign")) {
6551
- throw new Error(
6552
- "this CryptoKey cannot sign: generate it with usages ['sign', 'verify']"
6553
- );
6554
- }
6555
- let publicHex;
6556
- if (typeof publicKey === "string") {
6557
- fromHex(publicKey, "publicKey", 32);
6558
- publicHex = publicKey.trim().toLowerCase();
6559
- } else {
6560
- try {
6561
- publicHex = hex(
6562
- new Uint8Array(await crypto.subtle.exportKey("raw", publicKey))
6563
- );
6564
- } catch (cause) {
6565
- const detail = cause instanceof Error ? `: ${cause.message}` : "";
6566
- throw new Error(
6567
- "could not read the public half of this key pair; pass the public key as 64 hex instead" + detail
6568
- );
6569
- }
6570
- }
6571
- return fromKeyAndPublic(privateKey, publicHex);
6572
- }
6573
- function fromKeyAndPublic(privateKey, publicKey) {
6574
- return {
6575
- publicKey,
6576
- async sign(payload) {
6577
- try {
6578
- return new Uint8Array(
6579
- await crypto.subtle.sign({ name: "Ed25519" }, privateKey, payload)
6580
- );
6581
- } catch (cause) {
6582
- throw unsupported(cause);
6583
- }
6584
- }
6585
- };
6586
- }
6587
- async function resolveSigner(secret, signer, label2) {
6588
- if (signer && secret !== void 0) {
6589
- throw new Error(
6590
- `pass either ${label2} or signer, not both \u2014 they may name different keys`
6591
- );
6592
- }
6593
- if (signer) return signer;
6594
- if (secret !== void 0) return signerFromSecret(secret, label2);
6595
- throw new Error(`${label2} or signer is required`);
6596
- }
6597
-
6598
6914
  // src/warrant/warrant.ts
6599
6915
  var SIGN_DOMAIN2 = new TextEncoder().encode("calimero.warrant.v2");
6600
6916
  var INTENT_DOMAIN = new TextEncoder().encode("calimero.warrant.intent.v1");
@@ -7099,6 +7415,16 @@ var IntentRefusedError = class extends Error {
7099
7415
  this.name = "IntentRefusedError";
7100
7416
  }
7101
7417
  };
7418
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7419
+ function retryAfterMs(headers) {
7420
+ const raw = headers?.get?.("Retry-After");
7421
+ if (!raw) return null;
7422
+ const seconds = Number.parseInt(raw, 10);
7423
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
7424
+ const at = Date.parse(raw);
7425
+ if (Number.isFinite(at)) return Math.max(0, at - Date.now());
7426
+ return null;
7427
+ }
7102
7428
  var RelayClient = class {
7103
7429
  constructor(config) {
7104
7430
  this.config = config;
@@ -7107,11 +7433,40 @@ var RelayClient = class {
7107
7433
  const injected = config.fetch;
7108
7434
  this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
7109
7435
  this.timeoutMs = config.timeoutMs ?? 1e4;
7436
+ this.rateLimitRetries = config.rateLimitRetries ?? 3;
7110
7437
  }
7111
7438
  /**
7112
- * The author device's signer, built from `deviceSecret` when that is what was
7113
- * configured. A {@link Signer} and never the secret, so no caller of a
7114
- * `RelayClient` gains the ability to read key material.
7439
+ * The relay's origin, trailing slash stripped.
7440
+ *
7441
+ * Exposed because a relay **is a node**: the same origin that answers the
7442
+ * intents routes also answers `/auth/challenge`, `/auth/token`, `/sse` and
7443
+ * `/ws`. Anything building an observing session over this relay needs the
7444
+ * URL, and asking the caller to pass it a second time is how the two drift.
7445
+ */
7446
+ get relayUrl() {
7447
+ return this.baseUrl;
7448
+ }
7449
+ /** The author's account, hex — whose consent every warrant here carries. */
7450
+ get authorAccount() {
7451
+ return this.config.authorAccount;
7452
+ }
7453
+ /**
7454
+ * The author's `AccountProof<DeviceCert>`, hex.
7455
+ *
7456
+ * The same credential `login()` wants, which is why it is readable: a caller
7457
+ * that already handed it to this client should not have to hand an identical
7458
+ * copy to the login as well, where the two could disagree.
7459
+ */
7460
+ get authorProof() {
7461
+ return this.config.authorProof;
7462
+ }
7463
+ /**
7464
+ * The author device's signer.
7465
+ *
7466
+ * A {@link Signer} and never the secret: this returns something that can
7467
+ * *sign*, so no caller of a `RelayClient` gains the ability to read a key
7468
+ * that was passed in as `deviceSecret`. That asymmetry is the point — the
7469
+ * login needs a signature, not key material.
7115
7470
  */
7116
7471
  async authorSigner() {
7117
7472
  return resolveSigner(this.config.deviceSecret, this.config.signer, "deviceSecret");
@@ -7390,7 +7745,37 @@ var RelayClient = class {
7390
7745
  this.executorAccount = answered;
7391
7746
  return answered;
7392
7747
  }
7748
+ /**
7749
+ * One request, waiting out a rate limit rather than failing the caller.
7750
+ *
7751
+ * The relay's ingress limits the delegated-execution routes to 20 requests a
7752
+ * second per source IP, burst 40 — tighter than everything else it serves,
7753
+ * because those two routes are the ones it answers to anonymous callers.
7754
+ *
7755
+ * Retrying a 429 is safe here in a way retrying most refusals is not: the
7756
+ * limiter sits *first* in the middleware chain, so a throttled request never
7757
+ * reached the node and its warrant nonce was never spent. The same bytes can
7758
+ * go again. Contrast the spent-nonce 403 below, which needs a fresh warrant.
7759
+ *
7760
+ * `Retry-After` is honoured when the ingress sends one; otherwise the wait
7761
+ * doubles from a little over the limiter's own window, which is the shortest
7762
+ * delay that can actually clear.
7763
+ */
7393
7764
  async json(method, path, body) {
7765
+ let wait = 250;
7766
+ for (let attempt = 0; ; attempt += 1) {
7767
+ try {
7768
+ return await this.jsonOnce(method, path, body);
7769
+ } catch (err) {
7770
+ const limited = err instanceof HTTPError && err.status === 429;
7771
+ if (!limited || attempt >= this.rateLimitRetries) throw err;
7772
+ const after = retryAfterMs(err.headers);
7773
+ await sleep(after ?? wait);
7774
+ wait *= 2;
7775
+ }
7776
+ }
7777
+ }
7778
+ async jsonOnce(method, path, body) {
7394
7779
  const headers = { Accept: "application/json" };
7395
7780
  if (body !== void 0) headers["Content-Type"] = "application/json";
7396
7781
  const controller = new AbortController();
@@ -7443,6 +7828,668 @@ function extractReason(bodyText) {
7443
7828
  return bodyText;
7444
7829
  }
7445
7830
 
7831
+ // src/transport/relay-transport.ts
7832
+ var RelayTransport = class {
7833
+ constructor(relay, observer = null) {
7834
+ this.relay = relay;
7835
+ this.observer = observer;
7836
+ this.kind = "relay";
7837
+ }
7838
+ /**
7839
+ * Whether this relay can be observed — a fact, not a constant.
7840
+ *
7841
+ * A relay IS a node: it serves `/auth/challenge`, `/auth/token`, `/sse` and
7842
+ * `/ws`, and the `account_proof` provider grants `context:subscribe` by
7843
+ * default. What decides the answer is therefore not the transport but whether
7844
+ * a caller supplied the node's device signing key, which cannot be inferred
7845
+ * from the node or from its `peerId`. See `./relay-observer.ts`.
7846
+ */
7847
+ get canSubscribe() {
7848
+ return this.observer !== null;
7849
+ }
7850
+ /** The event session, or `null` when no node key was supplied. */
7851
+ get events() {
7852
+ return this.observer;
7853
+ }
7854
+ /** The underlying client, for `describe()` and anything else relay-specific. */
7855
+ get client() {
7856
+ return this.relay;
7857
+ }
7858
+ async execute(params) {
7859
+ const { returns } = await this.executeWithMetadata(params);
7860
+ return returns;
7861
+ }
7862
+ async executeWithMetadata(params) {
7863
+ const result = await this.relay.execute(
7864
+ params.contextId,
7865
+ params.method,
7866
+ // The node defaults a missing `argsJson` to `{}`; matching that here keeps
7867
+ // a zero-argument call identical on both transports rather than sending
7868
+ // `undefined` and letting the relay's JSON body decide.
7869
+ params.argsJson ?? {}
7870
+ );
7871
+ return {
7872
+ // Verbatim. `RelayClient` has already normalized a missing `returns` to
7873
+ // `null`, which is what a node reports for a null output — see the
7874
+ // module note on why this is not rewritten to `undefined`.
7875
+ returns: result.returns,
7876
+ transport: "relay",
7877
+ rootHash: result.rootHash
7878
+ };
7879
+ }
7880
+ /**
7881
+ * Both of these are plain context methods, so they work through a relay
7882
+ * exactly as they do on a node — the warrant names `migrate_my_entries` and
7883
+ * the delta is attributed to the author, which is precisely the identity the
7884
+ * convert is scoped to. They are re-declared rather than inherited because
7885
+ * `ExecuteTransport` is an interface, not a base class.
7886
+ */
7887
+ async migrateMyEntries(contextId) {
7888
+ return this.execute({
7889
+ contextId,
7890
+ method: "migrate_my_entries"
7891
+ });
7892
+ }
7893
+ /** Read-only count of the caller's entries still below the target schema. */
7894
+ async countMyPending(contextId) {
7895
+ return this.execute({ contextId, method: "count_my_pending" });
7896
+ }
7897
+ };
7898
+
7899
+ // src/login/login.ts
7900
+ var SIGN_DOMAIN5 = new TextEncoder().encode("calimero.auth.login.v1");
7901
+ var AUDIENCE_TAG = {
7902
+ webOrigin: 0,
7903
+ codeSigningId: 1,
7904
+ cli: 2
7905
+ };
7906
+ function audienceBody(audience) {
7907
+ const encoder = new TextEncoder();
7908
+ switch (audience.kind) {
7909
+ case "webOrigin":
7910
+ return encoder.encode(audience.origin);
7911
+ case "codeSigningId":
7912
+ return encoder.encode(audience.id);
7913
+ case "cli":
7914
+ return new Uint8Array(0);
7915
+ }
7916
+ }
7917
+ function audienceSigningBytes(audience) {
7918
+ return concat(
7919
+ Uint8Array.from([AUDIENCE_TAG[audience.kind]]),
7920
+ audienceBody(audience)
7921
+ );
7922
+ }
7923
+ function audienceWire(audience) {
7924
+ const tag2 = Uint8Array.from([AUDIENCE_TAG[audience.kind]]);
7925
+ if (audience.kind === "cli") {
7926
+ return tag2;
7927
+ }
7928
+ const body = audienceBody(audience);
7929
+ return concat(tag2, u32le(body.length), body);
7930
+ }
7931
+ async function signLoginStatement(input) {
7932
+ const node = fromHex(input.node, "node", 32);
7933
+ const challenge = fromHex(input.challenge, "challenge", 32);
7934
+ const sessionKey = fromHex(input.sessionKey, "sessionKey", 32);
7935
+ const signer = await resolveSigner(
7936
+ input.deviceSecret,
7937
+ input.signer,
7938
+ "deviceSecret"
7939
+ );
7940
+ const deviceKey = fromHex(signer.publicKey, "signer.publicKey", 32);
7941
+ const issuedAt = u64le(input.issuedAt);
7942
+ const expiresAt = u64le(input.expiresAt);
7943
+ const preimage = await domainHash(SIGN_DOMAIN5, [
7944
+ node,
7945
+ audienceSigningBytes(input.audience),
7946
+ challenge,
7947
+ sessionKey,
7948
+ deviceKey,
7949
+ issuedAt,
7950
+ expiresAt
7951
+ ]);
7952
+ const signature = await signer.sign(preimage);
7953
+ return hex(
7954
+ concat(
7955
+ node,
7956
+ audienceWire(input.audience),
7957
+ challenge,
7958
+ sessionKey,
7959
+ deviceKey,
7960
+ issuedAt,
7961
+ expiresAt,
7962
+ signature
7963
+ )
7964
+ );
7965
+ }
7966
+
7967
+ // src/login/session.ts
7968
+ var DEFAULT_TTL_SECONDS2 = 300;
7969
+ async function generateSessionKey() {
7970
+ const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
7971
+ return { publicKey: hex(await derivePublicKey(secret)), secret };
7972
+ }
7973
+ async function login(config) {
7974
+ const baseUrl = config.nodeUrl.replace(/\/+$/, "");
7975
+ const timeoutMs = config.timeoutMs ?? 1e4;
7976
+ const request = async (path, init) => {
7977
+ const controller = new AbortController();
7978
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
7979
+ let response;
7980
+ try {
7981
+ const requestInit = { ...init, signal: controller.signal };
7982
+ response = config.fetch ? await config.fetch(`${baseUrl}${path}`, requestInit) : await globalThis.fetch(`${baseUrl}${path}`, requestInit);
7983
+ } catch (err) {
7984
+ throw new HTTPError(
7985
+ 0,
7986
+ err instanceof Error ? err.name : "NetworkError",
7987
+ `${baseUrl}${path}`,
7988
+ new Headers(),
7989
+ err instanceof Error ? err.message : String(err)
7990
+ );
7991
+ } finally {
7992
+ clearTimeout(timer);
7993
+ }
7994
+ const text = await response.text().catch(() => "");
7995
+ if (!response.ok) {
7996
+ throw new HTTPError(
7997
+ response.status,
7998
+ response.statusText,
7999
+ `${baseUrl}${path}`,
8000
+ response.headers,
8001
+ text
8002
+ );
8003
+ }
8004
+ return text ? JSON.parse(text) : {};
8005
+ };
8006
+ const challengeBody = await request("/auth/challenge", {
8007
+ method: "GET",
8008
+ headers: { Accept: "application/json" }
8009
+ });
8010
+ const challenge = challengeBody.challenge ?? challengeBody.data?.challenge;
8011
+ if (!challenge) {
8012
+ throw new Error("the node issued no challenge");
8013
+ }
8014
+ const session = await generateSessionKey();
8015
+ const issuedAt = Math.floor(Date.now() / 1e3);
8016
+ const statement = await signLoginStatement({
8017
+ node: config.node,
8018
+ audience: config.audience,
8019
+ challenge,
8020
+ sessionKey: session.publicKey,
8021
+ issuedAt,
8022
+ expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS2),
8023
+ // Resolved once and handed on, so a config carrying a secret and a config
8024
+ // carrying a `CryptoKey` take exactly the same path from here down.
8025
+ signer: await resolveSigner(config.deviceSecret, config.signer, "deviceSecret")
8026
+ });
8027
+ const tokenBody = await request("/auth/token", {
8028
+ method: "POST",
8029
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
8030
+ body: JSON.stringify({
8031
+ auth_method: "account_proof",
8032
+ public_key: session.publicKey,
8033
+ client_name: config.clientName ?? baseUrl,
8034
+ timestamp: issuedAt,
8035
+ provider_data: {
8036
+ challenge,
8037
+ login_statement: statement,
8038
+ account_proof: config.accountProof
8039
+ }
8040
+ })
8041
+ });
8042
+ const accessToken = tokenBody.access_token ?? tokenBody.data?.access_token;
8043
+ const refreshToken = tokenBody.refresh_token ?? tokenBody.data?.refresh_token;
8044
+ if (!accessToken) {
8045
+ throw new Error("the node minted no session");
8046
+ }
8047
+ return {
8048
+ accessToken,
8049
+ refreshToken: refreshToken ?? "",
8050
+ sessionKey: session.publicKey,
8051
+ sessionSecret: session.secret
8052
+ };
8053
+ }
8054
+
8055
+ // src/transport/relay-observer.ts
8056
+ var EXPIRY_SKEW_MS = 3e4;
8057
+ function defaultAudience() {
8058
+ const origin = globalThis.location?.origin;
8059
+ return origin ? { kind: "webOrigin", origin } : { kind: "cli" };
8060
+ }
8061
+ function jwtExpiryMs(token) {
8062
+ try {
8063
+ const parts = token.split(".");
8064
+ if (parts.length !== 3) return null;
8065
+ let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
8066
+ while (b64.length % 4) b64 += "=";
8067
+ const payload = JSON.parse(atob(b64));
8068
+ return typeof payload.exp === "number" ? payload.exp * 1e3 : null;
8069
+ } catch {
8070
+ return null;
8071
+ }
8072
+ }
8073
+ var RelayObserver = class {
8074
+ constructor(config) {
8075
+ this.session = null;
8076
+ this.sessionExpiresAt = null;
8077
+ /** In-flight login, so two surfaces asking at once mint one session, not two. */
8078
+ this.pending = null;
8079
+ this.sseClient = null;
8080
+ this.wsClient = null;
8081
+ this.config = config;
8082
+ }
8083
+ /** The node key this observer logs in with. Read for diagnostics. */
8084
+ get nodeKey() {
8085
+ return this.config.nodeKey;
8086
+ }
8087
+ /**
8088
+ * A usable access token, logging in if there is not one.
8089
+ *
8090
+ * Re-mints once the current token is within {@link EXPIRY_SKEW_MS} of
8091
+ * expiring, and on every call when the token's lifetime cannot be read —
8092
+ * reconnects are backed off, so an extra handshake costs far less than a
8093
+ * stream that silently stops. The refresh token is *held* but not spent:
8094
+ * rotating it is tracked in #87/#88 and a second login is correct in the
8095
+ * meantime, where re-presenting an unrotated refresh token would not be.
8096
+ */
8097
+ async accessToken() {
8098
+ const now = Date.now();
8099
+ if (this.session && this.sessionExpiresAt !== null && this.sessionExpiresAt - EXPIRY_SKEW_MS > now) {
8100
+ return this.session.accessToken;
8101
+ }
8102
+ if (!this.pending) {
8103
+ this.pending = this.loginNow().finally(() => {
8104
+ this.pending = null;
8105
+ });
8106
+ }
8107
+ const session = await this.pending;
8108
+ return session.accessToken;
8109
+ }
8110
+ async loginNow() {
8111
+ const session = await login({
8112
+ nodeUrl: this.config.nodeUrl,
8113
+ node: this.config.nodeKey,
8114
+ signer: await this.config.signer(),
8115
+ accountProof: this.config.accountProof,
8116
+ audience: this.config.audience,
8117
+ clientName: this.config.clientName,
8118
+ ttlSeconds: this.config.ttlSeconds,
8119
+ fetch: this.config.fetch,
8120
+ timeoutMs: this.config.timeoutMs
8121
+ });
8122
+ this.session = session;
8123
+ this.sessionExpiresAt = jwtExpiryMs(session.accessToken);
8124
+ return session;
8125
+ }
8126
+ /**
8127
+ * SSE events over the relay node — the same `SseClient` a node client hands
8128
+ * out, constructed the same way, so nothing downstream can tell which
8129
+ * transport produced it.
8130
+ */
8131
+ get events() {
8132
+ if (!this.sseClient) {
8133
+ this.sseClient = new SseClient({
8134
+ baseUrl: this.config.nodeUrl,
8135
+ getAuthToken: () => this.accessToken()
8136
+ });
8137
+ }
8138
+ return this.sseClient;
8139
+ }
8140
+ /** WebSocket events over the relay node. @experimental — prefer `events`. */
8141
+ get ws() {
8142
+ if (!this.wsClient) {
8143
+ this.wsClient = new WsClient({
8144
+ baseUrl: this.config.nodeUrl,
8145
+ getAuthToken: () => this.accessToken()
8146
+ });
8147
+ }
8148
+ return this.wsClient;
8149
+ }
8150
+ /** Close whatever was opened. Never touches the relay's write path. */
8151
+ close() {
8152
+ this.sseClient?.close();
8153
+ this.wsClient?.close();
8154
+ }
8155
+ };
8156
+
8157
+ // src/request-proof/request-proof.ts
8158
+ var utf8 = (text) => new TextEncoder().encode(text);
8159
+ var REQUEST_SIGN_DOMAIN = utf8("calimero.auth.request.v1");
8160
+ var REQUEST_BODY_DOMAIN = utf8("calimero.auth.request.body.v1");
8161
+ var REQUEST_PROOF_HEADER = "X-Calimero-Proof";
8162
+ var DEFAULT_VALID_FOR = 300;
8163
+ async function signRequestProof(input) {
8164
+ const { method, path, credential } = input;
8165
+ if (!method) throw new Error("signRequestProof needs the method the request will use");
8166
+ if (!path.startsWith("/")) {
8167
+ throw new Error(`signRequestProof needs a path, got ${JSON.stringify(path)}`);
8168
+ }
8169
+ if (path.includes("?")) {
8170
+ throw new Error(
8171
+ `the path must carry no query string, got ${JSON.stringify(path)} \u2014 a query is excluded from the signature on purpose, because a proxy may rewrite one. Anything that must be bound belongs in the body.`
8172
+ );
8173
+ }
8174
+ const bodyBytes2 = input.body === void 0 ? new Uint8Array() : typeof input.body === "string" ? utf8(input.body) : input.body;
8175
+ const bodyHash = await domainHash(REQUEST_BODY_DOMAIN, [bodyBytes2]);
8176
+ const issuedAt = Math.floor(input.now ?? Date.now() / 1e3);
8177
+ const expiresAt = issuedAt + (input.validForSeconds ?? DEFAULT_VALID_FOR);
8178
+ const payload = await domainHash(REQUEST_SIGN_DOMAIN, [
8179
+ utf8(method),
8180
+ utf8(path),
8181
+ bodyHash,
8182
+ u64le(issuedAt),
8183
+ u64le(expiresAt)
8184
+ ]);
8185
+ const signature = await signPayload(payload, input);
8186
+ if (signature.length !== 64) {
8187
+ throw new Error(`the signer returned ${signature.length} bytes, not a 64-byte signature`);
8188
+ }
8189
+ const requestSig = concat(
8190
+ u32le(utf8(method).length),
8191
+ utf8(method),
8192
+ u32le(utf8(path).length),
8193
+ utf8(path),
8194
+ bodyHash,
8195
+ u64le(issuedAt),
8196
+ u64le(expiresAt),
8197
+ signature
8198
+ );
8199
+ const accountProof = hexToBytes(credential, "credential");
8200
+ return hex(concat(accountProof, new Uint8Array([0]), requestSig));
8201
+ }
8202
+ async function signPayload(payload, input) {
8203
+ if (input.signer) return input.signer.sign(payload);
8204
+ if (!input.deviceSecret) {
8205
+ throw new Error("signRequestProof needs either `deviceSecret` or `signer`");
8206
+ }
8207
+ const key = await importSigningKey(input.deviceSecret);
8208
+ return new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, payload));
8209
+ }
8210
+ function hexToBytes(value, label2) {
8211
+ const clean = value.trim();
8212
+ if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length % 2 !== 0) {
8213
+ throw new Error(`${label2} is not an even-length hex string`);
8214
+ }
8215
+ return fromHex(clean, label2, clean.length / 2);
8216
+ }
8217
+
8218
+ // src/request-proof/authorizer.ts
8219
+ function createProofAuthorizer(credential) {
8220
+ if (!credential.credential) {
8221
+ throw new Error("a proof authorizer needs the `credential` from signDeviceCert");
8222
+ }
8223
+ if (!credential.deviceSecret && !credential.signer) {
8224
+ throw new Error("a proof authorizer needs either `deviceSecret` or `signer`");
8225
+ }
8226
+ return async ({ method, path, body }) => {
8227
+ const proof = await signRequestProof({
8228
+ method,
8229
+ path,
8230
+ body,
8231
+ credential: credential.credential,
8232
+ deviceSecret: credential.deviceSecret,
8233
+ signer: credential.signer,
8234
+ validForSeconds: credential.validForSeconds
8235
+ });
8236
+ return { [REQUEST_PROOF_HEADER]: proof };
8237
+ };
8238
+ }
8239
+ function signedPath(url) {
8240
+ return new URL(url).pathname;
8241
+ }
8242
+
8243
+ // src/transport/mero-client.ts
8244
+ function relayHasNo(surface, why) {
8245
+ return new Error(
8246
+ `this client was constructed with the relay transport, which has no ${surface}: ${why}. Construct a node client (createMeroClient({ baseUrl })) for that surface \u2014 nothing here will silently connect to a node you did not choose.`
8247
+ );
8248
+ }
8249
+ function missingNodeKey(surface) {
8250
+ return new Error(
8251
+ `this relay-transport client cannot open ${surface}: it was given no relay node key. A relay is an ordinary node and does serve /sse and /ws \u2014 but the login statement must be signed against the node's device signing key, learned out of band (never read from the node, and never derived from its peerId). Pass it as \`observe: { nodeKey }\`. For a cloud-hosted relay the cloud does not publish it yet \u2014 see mdma #312.`
8252
+ );
8253
+ }
8254
+ function buildObserver(relay, observe) {
8255
+ const nodeKey = observe?.nodeKey;
8256
+ if (!nodeKey) return null;
8257
+ return new RelayObserver({
8258
+ nodeUrl: observe?.nodeUrl ?? relay.relayUrl,
8259
+ nodeKey,
8260
+ accountProof: observe?.accountProof ?? relay.authorProof,
8261
+ audience: observe?.audience ?? defaultAudience(),
8262
+ signer: observe?.signer ? async () => observe.signer : () => relay.authorSigner(),
8263
+ clientName: observe?.clientName,
8264
+ ttlSeconds: observe?.ttlSeconds,
8265
+ fetch: observe?.fetch,
8266
+ timeoutMs: observe?.timeoutMs
8267
+ });
8268
+ }
8269
+ var MeroClient = class {
8270
+ constructor(config) {
8271
+ /** The relay's event session, or `null` on a node client / with no node key. */
8272
+ this.relayObserver = null;
8273
+ /** Admin reads against the relay, when a session or a proof was supplied. */
8274
+ this.relayAdmin = null;
8275
+ /**
8276
+ * Events off the relay authenticated by a request proof, built lazily.
8277
+ *
8278
+ * Separate from {@link relayObserver} rather than a mode inside it: the
8279
+ * observer's whole subject is a session addressed to a node key, and a proof
8280
+ * has neither. Holding the authorizer instead of the client keeps construction
8281
+ * free of the first signature.
8282
+ */
8283
+ this.proofAuthorizer = null;
8284
+ this.relayUrl = null;
8285
+ this.proofEvents = null;
8286
+ if (config.transport === "relay") {
8287
+ this.transport = "relay";
8288
+ this.nodeClient = null;
8289
+ const relay = config.relay instanceof RelayClient ? config.relay : new RelayClient(config.relay);
8290
+ this.relayObserver = buildObserver(relay, config.observe);
8291
+ this.rpcTransport = new RelayTransport(relay, this.relayObserver);
8292
+ this.relayUrl = relay.relayUrl;
8293
+ if (config.proof) {
8294
+ this.proofAuthorizer = createProofAuthorizer(config.proof);
8295
+ }
8296
+ if (config.session) {
8297
+ const token = config.session;
8298
+ this.relayAdmin = createAdminApiClient({
8299
+ baseUrl: relay.relayUrl,
8300
+ getAuthToken: typeof token === "string" ? async () => token : token
8301
+ });
8302
+ } else if (this.proofAuthorizer) {
8303
+ const authorizeRequest = this.proofAuthorizer;
8304
+ this.relayAdmin = createAdminApiClientFromHttpClient(
8305
+ createHttpClient({
8306
+ fetch: (url, init) => globalThis.fetch(url, init),
8307
+ baseUrl: relay.relayUrl,
8308
+ authorizeRequest
8309
+ }),
8310
+ { baseUrl: relay.relayUrl }
8311
+ );
8312
+ }
8313
+ return;
8314
+ }
8315
+ this.transport = "node";
8316
+ const { transport: _ignored, ...nodeConfig } = config;
8317
+ this.nodeClient = new MeroJs(nodeConfig);
8318
+ this.rpcTransport = this.nodeClient.rpc;
8319
+ }
8320
+ /**
8321
+ * The write surface. Identical on both transports — this is the whole point.
8322
+ *
8323
+ * On a node this IS the `RpcClient` a `MeroJs` would have handed out, not a
8324
+ * wrapper around it, so `client.rpc` and `client.node.rpc` are the same
8325
+ * object.
8326
+ */
8327
+ get rpc() {
8328
+ return this.rpcTransport;
8329
+ }
8330
+ /**
8331
+ * Whether `events` / `ws` will work on this client.
8332
+ *
8333
+ * Always `true` on the node transport. On the relay transport it says whether
8334
+ * a node key was supplied — the relay can be observed, but only by a caller
8335
+ * who can name the key the login statement is signed against.
8336
+ */
8337
+ get canSubscribe() {
8338
+ return this.rpcTransport.canSubscribe || this.proofAuthorizer !== null;
8339
+ }
8340
+ /** The underlying `MeroJs`. Node transport only. */
8341
+ get node() {
8342
+ if (!this.nodeClient) {
8343
+ throw relayHasNo("node connection", "it holds a signing key and a relay URL, not a node");
8344
+ }
8345
+ return this.nodeClient;
8346
+ }
8347
+ /** The underlying `RelayClient`, for `describe()`. Relay transport only. */
8348
+ get relay() {
8349
+ if (!(this.rpcTransport instanceof RelayTransport)) {
8350
+ throw new Error("this client was constructed with the node transport and has no relay");
8351
+ }
8352
+ return this.rpcTransport.client;
8353
+ }
8354
+ /**
8355
+ * Admin API. Node transport only.
8356
+ *
8357
+ * Two different things live behind this accessor today, and only one of them
8358
+ * is genuinely unavailable through a relay.
8359
+ *
8360
+ * The **mutations** — `createNamespace`, `setMemberCapabilities`,
8361
+ * `upgradeGroup` and the rest — are operator actions on somebody else's
8362
+ * machine. A keyholder does not get those, and should not; admin is for
8363
+ * admins.
8364
+ *
8365
+ * The **reads** are a different matter. `getContext`, `getContexts`,
8366
+ * `getApplication`, `listNamespaces`, `getBlob` are an app asking what it is
8367
+ * looking at, not an operator administering anything. They sit under
8368
+ * `/admin-api/` for historical reasons, and a delegated session is refused
8369
+ * them (measured: `403`, while the same session reads context state through
8370
+ * `POST /contexts/{ctx}/query` and writes through `/intents` quite happily).
8371
+ *
8372
+ * So this is the one place where "the same code runs on both transports"
8373
+ * does not hold yet, and it is a missing surface rather than a missing
8374
+ * permission. A transport-independent read API is designed but not built; the
8375
+ * throw below is deliberate until it exists, because the alternative — quietly
8376
+ * reaching for a node the caller did not choose — is worse than a clear stop.
8377
+ */
8378
+ /**
8379
+ * The admin surface.
8380
+ *
8381
+ * On a node client this is the node's own. On a relay client it exists only
8382
+ * when a `session` was supplied, and it is **the relay's admin API
8383
+ * authenticated as the account** — so the caller-scoped reads answer:
8384
+ * `getContexts` and `listNamespaces` return this account's own, because
8385
+ * `account_proof` grants `context:list-own` and `namespace:list-own`.
8386
+ *
8387
+ * Mutations are not filtered out here, deliberately. A delegated session does
8388
+ * not carry the grants they require, so the node refuses them with a `403`
8389
+ * that names the missing permission — which is a better answer than a
8390
+ * client-side guess, and keeps the policy in the one place that owns it.
8391
+ */
8392
+ get admin() {
8393
+ if (this.nodeClient) return this.nodeClient.admin;
8394
+ if (this.relayAdmin) return this.relayAdmin;
8395
+ throw new Error(
8396
+ "this relay-transport client has no admin surface because it was given neither a `proof` nor a `session`. A warrant authorises one intent and is not a credential for reading. The caller-scoped reads (getContexts, listNamespaces) want one of the other two: pass `proof: { credential, deviceSecret }` \u2014 the device cert you already hold, which needs no login and no node key \u2014 or a `session` minted with `login()`. Context STATE reads work through execute/query either way."
8397
+ );
8398
+ }
8399
+ /** Auth API. Node transport only — a relay issues no node credential. */
8400
+ get auth() {
8401
+ return this.node.auth;
8402
+ }
8403
+ /**
8404
+ * SSE events — on **both** transports.
8405
+ *
8406
+ * On a relay client this is an `SseClient` against the relay node, fed by a
8407
+ * session minted with the author's account proof. The session is minted
8408
+ * lazily, on the first token the stream asks for, so reading this getter
8409
+ * performs no network call.
8410
+ *
8411
+ * Throws only when the relay client was given no node key: the one input
8412
+ * that cannot be inferred. See {@link missingNodeKey}.
8413
+ */
8414
+ get events() {
8415
+ if (this.nodeClient) return this.nodeClient.events;
8416
+ if (this.relayObserver) return this.relayObserver.events;
8417
+ if (this.proofAuthorizer && this.relayUrl) {
8418
+ this.proofEvents ?? (this.proofEvents = new SseClient({
8419
+ baseUrl: this.relayUrl,
8420
+ authorize: this.proofAuthorizer
8421
+ }));
8422
+ return this.proofEvents;
8423
+ }
8424
+ throw missingNodeKey("SSE events");
8425
+ }
8426
+ /** WebSocket events, on both transports. @experimental — prefer `events`. */
8427
+ get ws() {
8428
+ if (this.nodeClient) return this.nodeClient.ws;
8429
+ if (this.relayObserver) return this.relayObserver.ws;
8430
+ if (this.proofAuthorizer) {
8431
+ throw new Error(
8432
+ "this client authenticates with a request proof, which cannot open a WebSocket from a browser: `WebSocket` cannot set the X-Calimero-Proof header, and core's guard reads the proof from a header only (the ?token= query parameter is the session equivalent and has no proof counterpart). Use `client.events` \u2014 SSE is fetch-based, carries the header, and is the recommended transport anyway."
8433
+ );
8434
+ }
8435
+ throw missingNodeKey("WebSocket events");
8436
+ }
8437
+ /**
8438
+ * Ephemeral presence. Node transport only.
8439
+ *
8440
+ * Not extended to the relay even though a logged-in session could reach both
8441
+ * halves of it mechanically. Publishing resolves the author from an *owned
8442
+ * context identity on the node*, and whether a delegated session has one has
8443
+ * not been established — a presence client that silently published nothing,
8444
+ * or published as the wrong identity, is worse than one that says it is not
8445
+ * available. The read half is not lost: presence arrives on `client.events`
8446
+ * like any other context event, and can be filtered there.
8447
+ *
8448
+ * The rejected alternative was to expose it over the relay session and let
8449
+ * `set` fail at the node. That trades a clear construction-time answer for a
8450
+ * runtime failure inside a UI that has already rendered.
8451
+ */
8452
+ get ephemeral() {
8453
+ if (!this.nodeClient) {
8454
+ throw relayHasNo(
8455
+ "ephemeral presence",
8456
+ "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"
8457
+ );
8458
+ }
8459
+ return this.nodeClient.ephemeral;
8460
+ }
8461
+ /**
8462
+ * Calimero Cloud.
8463
+ *
8464
+ * Available on both transports, because it is neither: the cloud session is
8465
+ * independent of the node credential and of the relay's signing key, and a
8466
+ * relay-transport app still needs it to list namespaces and manage billing.
8467
+ */
8468
+ get cloud() {
8469
+ if (!this.nodeClient) {
8470
+ throw relayHasNo(
8471
+ "bundled cloud client",
8472
+ "a relay client is built from a cloud connection rather than owning one \u2014 reach it through the `cloud` field that `connectCloud` returns, or construct a `CloudClient` directly"
8473
+ );
8474
+ }
8475
+ return this.nodeClient.cloud;
8476
+ }
8477
+ /**
8478
+ * Release whatever the transport is holding.
8479
+ *
8480
+ * Safe on a relay client, where there is nothing open to close — a caller
8481
+ * that tears down on unmount should not have to know which transport it got.
8482
+ */
8483
+ close() {
8484
+ this.nodeClient?.close();
8485
+ this.relayObserver?.close();
8486
+ this.proofEvents?.close();
8487
+ }
8488
+ };
8489
+ function createMeroClient(config) {
8490
+ return new MeroClient(config);
8491
+ }
8492
+
7446
8493
  // src/relay/nonce-source.ts
7447
8494
  function createMemoryNonceSource(start = 1) {
7448
8495
  let next = BigInt(start);
@@ -7454,6 +8501,10 @@ function createMemoryNonceSource(start = 1) {
7454
8501
  const value = next;
7455
8502
  next += 1n;
7456
8503
  return Promise.resolve(value);
8504
+ },
8505
+ advanceTo(floor) {
8506
+ if (floor > next) next = floor;
8507
+ return Promise.resolve();
7457
8508
  }
7458
8509
  };
7459
8510
  }
@@ -7464,21 +8515,101 @@ function createLocalStorageNonceSource(key, storage) {
7464
8515
  "no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence"
7465
8516
  );
7466
8517
  }
8518
+ const lastHandedOut = () => {
8519
+ const raw = store.getItem(key);
8520
+ if (raw === null || raw === "") return 0n;
8521
+ try {
8522
+ const parsed = BigInt(raw);
8523
+ return parsed > 0n ? parsed : 0n;
8524
+ } catch {
8525
+ return 0n;
8526
+ }
8527
+ };
7467
8528
  return {
7468
8529
  next() {
7469
- const raw = store.getItem(key);
7470
- let current = 0n;
7471
- if (raw !== null && raw !== "") {
7472
- try {
7473
- const parsed = BigInt(raw);
7474
- if (parsed > 0n) current = parsed;
7475
- } catch {
7476
- current = 0n;
7477
- }
8530
+ const value = lastHandedOut() + 1n;
8531
+ store.setItem(key, value.toString());
8532
+ return Promise.resolve(value);
8533
+ },
8534
+ advanceTo(floor) {
8535
+ const wanted = floor > 0n ? floor - 1n : 0n;
8536
+ if (wanted > lastHandedOut()) store.setItem(key, wanted.toString());
8537
+ return Promise.resolve();
8538
+ }
8539
+ };
8540
+ }
8541
+ function authorNonceLookup(options) {
8542
+ return {
8543
+ getWarrantNonce: (contextId, _authorDeviceKey) => options.client.getWarrantNonceAsAuthor(contextId, options.authorProof)
8544
+ };
8545
+ }
8546
+ var WarrantNonceExhaustedError = class extends Error {
8547
+ constructor(contextId, authorDeviceKey) {
8548
+ super(
8549
+ `author device ${authorDeviceKey} has spent every nonce in context ${contextId}; it must re-key to write again`
8550
+ );
8551
+ this.contextId = contextId;
8552
+ this.authorDeviceKey = authorDeviceKey;
8553
+ this.name = "WarrantNonceExhaustedError";
8554
+ }
8555
+ };
8556
+ function createRecoveringNonceSource(options) {
8557
+ const lookups = Array.isArray(options.lookups) ? options.lookups : [options.lookups];
8558
+ if (lookups.length === 0) {
8559
+ throw new Error("a recovering nonce source needs at least one node to ask");
8560
+ }
8561
+ const local = options.local ?? createMemoryNonceSource(1);
8562
+ const onLookupFailure = options.onLookupFailure ?? "throw";
8563
+ let floor = null;
8564
+ let pending = null;
8565
+ const resolveFloor = async () => {
8566
+ const answers = await Promise.allSettled(
8567
+ lookups.map(
8568
+ (lookup) => lookup.getWarrantNonce(options.contextId, options.authorDeviceKey)
8569
+ )
8570
+ );
8571
+ let highest = null;
8572
+ let exhausted = false;
8573
+ for (const answer of answers) {
8574
+ if (answer.status !== "fulfilled") continue;
8575
+ const state = answer.value;
8576
+ if (state.kind === "exhausted") {
8577
+ exhausted = true;
8578
+ continue;
8579
+ }
8580
+ if (highest === null || state.nextNonce > highest) highest = state.nextNonce;
8581
+ }
8582
+ if (highest === null && exhausted) {
8583
+ throw new WarrantNonceExhaustedError(options.contextId, options.authorDeviceKey);
8584
+ }
8585
+ if (highest === null) {
8586
+ const first = answers.find((a) => a.status === "rejected");
8587
+ const reason = first && first.status === "rejected" ? first.reason : void 0;
8588
+ if (onLookupFailure === "local") {
8589
+ floor = 0n;
8590
+ return;
8591
+ }
8592
+ throw reason instanceof Error ? reason : new Error(`no node could answer where the warrant-nonce sequence stands: ${String(reason)}`);
8593
+ }
8594
+ floor = highest;
8595
+ await local.advanceTo?.(highest);
8596
+ };
8597
+ return {
8598
+ async next() {
8599
+ if (floor === null) {
8600
+ pending ?? (pending = resolveFloor().finally(() => {
8601
+ pending = null;
8602
+ }));
8603
+ await pending;
7478
8604
  }
7479
- const value = current + 1n;
7480
- store.setItem(key, value.toString());
7481
- return Promise.resolve(value);
8605
+ const mine = await local.next();
8606
+ const value = floor !== null && floor > mine ? floor : mine;
8607
+ floor = value + 1n;
8608
+ return value;
8609
+ },
8610
+ async advanceTo(wanted) {
8611
+ if (floor === null || wanted > floor) floor = wanted;
8612
+ await local.advanceTo?.(wanted);
7482
8613
  }
7483
8614
  };
7484
8615
  }
@@ -7651,7 +8782,7 @@ var MAX_HANDSHAKE_REPLY = 1024;
7651
8782
  var RENEW_BEFORE_MS = 6e4;
7652
8783
  var TRANSPORT_BINDING_DOMAIN = "calimero.tee-attest.transport-key.v1";
7653
8784
  var NULL_BODY_STATUSES = [101, 204, 205, 304];
7654
- var utf8 = new TextEncoder();
8785
+ var utf82 = new TextEncoder();
7655
8786
  var StaleTransportKeyError = class extends Error {
7656
8787
  constructor() {
7657
8788
  super("The node no longer holds the attested transport key; attest again for the current one");
@@ -7659,18 +8790,18 @@ var StaleTransportKeyError = class extends Error {
7659
8790
  }
7660
8791
  };
7661
8792
  var SealedTransportError = class extends Error {
7662
- constructor(status, code, message, retryAfterMs) {
8793
+ constructor(status, code, message, retryAfterMs2) {
7663
8794
  super(message);
7664
8795
  this.status = status;
7665
8796
  this.code = code;
7666
- this.retryAfterMs = retryAfterMs;
8797
+ this.retryAfterMs = retryAfterMs2;
7667
8798
  this.name = "SealedTransportError";
7668
8799
  }
7669
8800
  };
7670
8801
  async function transportKeyBinding(inner, transportKey) {
7671
8802
  const digest = await crypto.subtle.digest(
7672
8803
  "SHA-256",
7673
- concat(utf8.encode(TRANSPORT_BINDING_DOMAIN), inner, transportKey)
8804
+ concat(utf82.encode(TRANSPORT_BINDING_DOMAIN), inner, transportKey)
7674
8805
  );
7675
8806
  return new Uint8Array(digest);
7676
8807
  }
@@ -7874,7 +9005,7 @@ function nonce(requestId, index) {
7874
9005
  return bytes;
7875
9006
  }
7876
9007
  function joinInner(head, body) {
7877
- const encoded = utf8.encode(JSON.stringify(head));
9008
+ const encoded = utf82.encode(JSON.stringify(head));
7878
9009
  const len = new Uint8Array(4);
7879
9010
  new DataView(len.buffer).setUint32(0, encoded.length, false);
7880
9011
  return concat(len, encoded, body);
@@ -7983,8 +9114,8 @@ async function envelopeRefusal(response) {
7983
9114
  }
7984
9115
  if (code === "stale_transport_key") return new StaleTransportKeyError();
7985
9116
  const retryAfter = Number(response.headers.get("retry-after"));
7986
- const retryAfterMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 5) * 1e3 : void 0;
7987
- return new SealedTransportError(response.status, code, message, retryAfterMs);
9117
+ const retryAfterMs2 = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 5) * 1e3 : void 0;
9118
+ return new SealedTransportError(response.status, code, message, retryAfterMs2);
7988
9119
  }
7989
9120
 
7990
9121
  // src/cloud/connect.ts
@@ -7992,6 +9123,7 @@ async function connectCloud(options) {
7992
9123
  if (!options.sessionToken && !options.googleIdToken) {
7993
9124
  throw new Error("connectCloud needs either a sessionToken or a googleIdToken");
7994
9125
  }
9126
+ const signer = await resolveSigner(options.deviceSecret, options.signer, "deviceSecret");
7995
9127
  const cloud = new CloudClient({
7996
9128
  cloudBaseUrl: options.cloudBaseUrl,
7997
9129
  sessionToken: options.sessionToken,
@@ -8017,8 +9149,8 @@ async function connectCloud(options) {
8017
9149
  executorAccount: relayInfo.executorAccount,
8018
9150
  authorAccount: options.authorAccount,
8019
9151
  authorProof: options.authorProof,
8020
- deviceSecret: options.deviceSecret,
8021
- nonces: options.nonces ?? await defaultNonceSource(options.deviceSecret),
9152
+ signer,
9153
+ nonces: options.nonces ?? defaultNonceSource(signer.publicKey),
8022
9154
  ttlSeconds: options.ttlSeconds,
8023
9155
  // Only the relay is sealed: a sealed fetch is bound to one node and refuses
8024
9156
  // any other URL, and the cloud calls above carry no intent.
@@ -8035,9 +9167,50 @@ async function connectCloud(options) {
8035
9167
  namespaceId: namespace.namespaceId,
8036
9168
  namespace,
8037
9169
  relayInfo,
8038
- execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson)
9170
+ execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson),
9171
+ // Built from the same `relay`, not a second one: one nonce sequence, one
9172
+ // warrant signer. Two clients over one relay would hand out the same nonce
9173
+ // twice and have the network refuse the loser as a replay.
9174
+ //
9175
+ // `observe` is always passed, with whatever key is known — the caller's if
9176
+ // they had one, otherwise the cloud's, which is `null` until mdma #312. One
9177
+ // code path rather than two, so the hosted case starts observing the day
9178
+ // the field appears, without a line changing here or at the call site.
9179
+ client: new MeroClient({
9180
+ transport: "relay",
9181
+ relay,
9182
+ observe: {
9183
+ nodeKey: options.relayNodeKey ?? relayInfo.nodeKey,
9184
+ audience: options.observeAudience,
9185
+ fetch: options.fetch,
9186
+ timeoutMs: options.timeoutMs
9187
+ }
9188
+ })
8039
9189
  };
8040
9190
  }
9191
+ async function connectCloudWithAccount(options) {
9192
+ const { root, authorAccount, ...rest } = options;
9193
+ const signer = await resolveSigner(rest.deviceSecret, rest.signer, "deviceSecret");
9194
+ const resolved = await resolveRoot(root);
9195
+ const cloud = new CloudClient({
9196
+ cloudBaseUrl: options.cloudBaseUrl,
9197
+ // Reported here as well as on the connection's own client: this is where
9198
+ // the session is first minted, and a caller persisting it would otherwise
9199
+ // only hear about the rolling refreshes of a token it never saw.
9200
+ onSession: options.onSession,
9201
+ fetch: options.fetch,
9202
+ timeoutMs: options.timeoutMs
9203
+ });
9204
+ const session = await cloud.signInWithAccount(resolved.signer);
9205
+ return connectCloud({
9206
+ ...rest,
9207
+ // Already resolved above, and passing both forms on would be refused.
9208
+ deviceSecret: void 0,
9209
+ signer,
9210
+ sessionToken: session.sessionToken,
9211
+ authorAccount: authorAccount ?? resolved.accountId
9212
+ });
9213
+ }
8041
9214
  function pickNamespace(namespaces, requested) {
8042
9215
  if (requested) {
8043
9216
  const found = namespaces.find((n) => n.namespaceId === requested);
@@ -8074,8 +9247,8 @@ function explainNoRelay(namespace, relays) {
8074
9247
  }
8075
9248
  return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none is ready to relay yet (a URL, an executor account and a recent heartbeat) \u2014 retry shortly`;
8076
9249
  }
8077
- async function defaultNonceSource(deviceSecret) {
8078
- const key = `mero-js:warrant-nonce:${hex(await derivePublicKey(deviceSecret))}`;
9250
+ function defaultNonceSource(devicePublicKey) {
9251
+ const key = `mero-js:warrant-nonce:${devicePublicKey}`;
8079
9252
  try {
8080
9253
  return createLocalStorageNonceSource(key);
8081
9254
  } catch {
@@ -8083,6 +9256,168 @@ async function defaultNonceSource(deviceSecret) {
8083
9256
  }
8084
9257
  }
8085
9258
 
9259
+ // src/cloud/link-redirect.ts
9260
+ var DEFAULT_CLOUD_WEB_URL = "https://cloud.calimero.network";
9261
+ function cloudLinkUrl(options) {
9262
+ if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
9263
+ throw new Error("accountId must be 64 lowercase hex characters");
9264
+ }
9265
+ const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
9266
+ url.searchParams.set("link-account", options.accountId);
9267
+ url.searchParams.set("callback-url", options.returnTo);
9268
+ return url.toString();
9269
+ }
9270
+ function beginCloudLink(options) {
9271
+ window.location.assign(cloudLinkUrl(options));
9272
+ throw new Error("redirecting to the cloud to authorise this link");
9273
+ }
9274
+ function readCloudLinkCallback(location2 = window.location) {
9275
+ const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
9276
+ const error = params.get("error");
9277
+ if (error) {
9278
+ stripFragment();
9279
+ throw new Error(
9280
+ error === "denied" ? "the account link was declined at the cloud" : `the cloud refused this link: ${error}`
9281
+ );
9282
+ }
9283
+ const grant = params.get("grant");
9284
+ const account = params.get("account");
9285
+ if (!grant || !account) return null;
9286
+ stripFragment();
9287
+ return { grant, account };
9288
+ }
9289
+ function stripFragment() {
9290
+ if (typeof history === "undefined" || !history.replaceState) return;
9291
+ history.replaceState(null, "", location.pathname + location.search);
9292
+ }
9293
+ async function completeCloudLink(options) {
9294
+ const root = await resolveRoot(options.root);
9295
+ if (root.accountId !== options.account) {
9296
+ throw new Error(
9297
+ `this grant is for account ${options.account}, but the root here derives ${root.accountId} \u2014 it was minted for a different account`
9298
+ );
9299
+ }
9300
+ const cloud = new CloudClient({
9301
+ cloudBaseUrl: options.cloudBaseUrl,
9302
+ fetch: options.fetch
9303
+ });
9304
+ return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
9305
+ }
9306
+
9307
+ // src/cloud/enrol-redirect.ts
9308
+ var DEVICE_PARAM = "enrol-device";
9309
+ var KEM_PARAM = "enrol-kem";
9310
+ var CALLBACK_PARAM = "callback-url";
9311
+ var STATE_PARAM = "state";
9312
+ function deviceEnrolmentUrl(options) {
9313
+ assertHex(options.devicePublicKey, "devicePublicKey");
9314
+ assertHex(options.kemPublicKey, "kemPublicKey");
9315
+ assertSafeReturnTo(options.returnTo);
9316
+ warnOnForeignCallback(options.returnTo);
9317
+ const url = new URL(options.walletUrl);
9318
+ url.searchParams.set(DEVICE_PARAM, options.devicePublicKey);
9319
+ url.searchParams.set(KEM_PARAM, options.kemPublicKey);
9320
+ url.searchParams.set(CALLBACK_PARAM, options.returnTo);
9321
+ if (options.state) url.searchParams.set(STATE_PARAM, options.state);
9322
+ return url.toString();
9323
+ }
9324
+ function beginDeviceEnrolment(options) {
9325
+ window.location.assign(deviceEnrolmentUrl(options));
9326
+ throw new Error("redirecting to the wallet to approve this device");
9327
+ }
9328
+ function readEnrolmentCallback(location2 = window.location) {
9329
+ const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
9330
+ const error = params.get("error");
9331
+ if (error) {
9332
+ stripFragment2();
9333
+ throw new Error(
9334
+ error === "denied" || error === "cancelled" ? "the device was not approved at the wallet" : `the wallet refused this enrolment: ${error}`
9335
+ );
9336
+ }
9337
+ const credential = params.get("credential");
9338
+ const account = params.get("account");
9339
+ const device = params.get("device");
9340
+ if (!credential || !account || !device) return null;
9341
+ stripFragment2();
9342
+ const state = params.get("state");
9343
+ return { credential, account, device, ...state ? { state } : {} };
9344
+ }
9345
+ function stripFragment2() {
9346
+ if (typeof history === "undefined" || !history.replaceState) return;
9347
+ history.replaceState(null, "", location.pathname + location.search);
9348
+ }
9349
+ async function completeDeviceEnrolment(options) {
9350
+ if (options.expectState !== void 0 && options.expectState !== options.state) {
9351
+ throw new Error(
9352
+ "this enrolment answers a request this app did not make \u2014 the state it carries is not the one sent"
9353
+ );
9354
+ }
9355
+ const certificate = await verifyDeviceCredential(options.credential);
9356
+ if (certificate.signPublicKey !== normaliseHex(options.devicePublicKey)) {
9357
+ throw new Error(
9358
+ `this credential certifies device key ${certificate.signPublicKey}, but this app holds ${normaliseHex(options.devicePublicKey)} \u2014 it would sign with a key the certificate does not cover`
9359
+ );
9360
+ }
9361
+ if (options.kemPublicKey !== void 0 && certificate.kemPublicKey !== normaliseHex(options.kemPublicKey)) {
9362
+ throw new Error(
9363
+ `this credential names delivery key ${certificate.kemPublicKey}, not the one this app asked about \u2014 group keys would be sealed to somebody else`
9364
+ );
9365
+ }
9366
+ if (certificate.account !== normaliseHex(options.account)) {
9367
+ throw new Error(
9368
+ `the wallet reported account ${normaliseHex(options.account)}, but the credential names ${certificate.account}`
9369
+ );
9370
+ }
9371
+ if (certificate.device !== normaliseHex(options.device)) {
9372
+ throw new Error(
9373
+ `the wallet reported device ${normaliseHex(options.device)}, but the credential names ${certificate.device}`
9374
+ );
9375
+ }
9376
+ return {
9377
+ credential: options.credential,
9378
+ account: certificate.account,
9379
+ device: certificate.device,
9380
+ certificate
9381
+ };
9382
+ }
9383
+ function assertHex(value, label2) {
9384
+ if (!/^[0-9a-f]{64}$/.test(value)) {
9385
+ throw new Error(`${label2} must be 64 lowercase hex characters`);
9386
+ }
9387
+ }
9388
+ function normaliseHex(value) {
9389
+ return value.trim().toLowerCase();
9390
+ }
9391
+ function callbackIsForeign(returnTo, pageOrigin) {
9392
+ const here = pageOrigin ?? (typeof window === "undefined" ? void 0 : window.location.origin);
9393
+ if (!here) return false;
9394
+ try {
9395
+ return new URL(returnTo).origin !== here;
9396
+ } catch {
9397
+ return false;
9398
+ }
9399
+ }
9400
+ function warnOnForeignCallback(returnTo) {
9401
+ if (!callbackIsForeign(returnTo)) return;
9402
+ console.warn(
9403
+ `[calimero] enrolling a device but sending the credential to ${new URL(returnTo).origin}, which is not this page's origin. Legitimate when deliberate; worth a second look otherwise.`
9404
+ );
9405
+ }
9406
+ function assertSafeReturnTo(returnTo) {
9407
+ let url;
9408
+ try {
9409
+ url = new URL(returnTo);
9410
+ } catch {
9411
+ throw new Error(`returnTo must be an absolute URL, got ${returnTo}`);
9412
+ }
9413
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
9414
+ if (url.protocol === "https:") return;
9415
+ if (url.protocol === "http:" && loopback) return;
9416
+ throw new Error(
9417
+ `returnTo must be https:, or http: on localhost \u2014 got ${url.protocol}//${url.hostname}`
9418
+ );
9419
+ }
9420
+
8086
9421
  // src/sealed/verify.ts
8087
9422
  var MEASUREMENT_HEX = /^[0-9a-f]{96}$/;
8088
9423
  var REGISTERS = ["mrtd", "rtmr0", "rtmr1", "rtmr2", "rtmr3"];
@@ -8451,7 +9786,7 @@ var FULCIO_ISSUER_V1 = "1.3.6.1.4.1.57264.1.1";
8451
9786
  var SAN_URI = 134;
8452
9787
  var UTF8_STRING = 12;
8453
9788
  var SEQUENCE2 = 48;
8454
- var utf82 = new TextEncoder();
9789
+ var utf83 = new TextEncoder();
8455
9790
  var fromUtf8 = (bytes) => new TextDecoder().decode(bytes);
8456
9791
  async function verifyCosignBundle(body, bundle, signer, signature) {
8457
9792
  const { base64Signature, cert, rekorBundle } = cosignBundle(bundle);
@@ -8464,7 +9799,7 @@ async function verifyCosignBundle(body, bundle, signer, signature) {
8464
9799
  const leaf = parseCertificate(leafDer);
8465
9800
  const log = REKOR_LOGS.find((candidate) => candidate.logId === payload.logID.toLowerCase());
8466
9801
  if (!log) throw new Error(`The bundle names Rekor log ${payload.logID}, which the Sigstore trust root does not know`);
8467
- const canonical = utf82.encode(
9802
+ const canonical = utf83.encode(
8468
9803
  `{"body":${JSON.stringify(payload.body)},"integratedTime":${payload.integratedTime},"logID":${JSON.stringify(payload.logID)},"logIndex":${payload.logIndex}}`
8469
9804
  );
8470
9805
  const setVerifies = await verifyEcdsa(
@@ -8859,186 +10194,22 @@ async function signMemberJoinOp(input) {
8859
10194
  credential
8860
10195
  );
8861
10196
  const op = concat(new Uint8Array([NAMESPACE_OP_ROOT]), rootOp);
8862
- const key = await importSigningKey(input.deviceSecret);
8863
- const signer = await derivePublicKey(input.deviceSecret);
10197
+ const signingKey = await resolveSigner(input.deviceSecret, input.signer, "deviceSecret");
10198
+ const signerPublicKey = fromHex(signingKey.publicKey, "signer.publicKey", 32);
8864
10199
  const signable = concat(
8865
10200
  new Uint8Array([SIGNED_NAMESPACE_OP_SCHEMA_VERSION]),
8866
10201
  fromHex(input.namespaceId, "namespaceId", 32),
8867
10202
  u32le(parents.length),
8868
10203
  ...parents.map((p, i) => fromHex(p, `parentOpHashes[${i}]`, 32)),
8869
- signer,
10204
+ signerPublicKey,
8870
10205
  u64le(input.nonce),
8871
10206
  op
8872
10207
  );
8873
- const signature = new Uint8Array(
8874
- await crypto.subtle.sign("Ed25519", key, concat(NAMESPACE_SIGN_DOMAIN, signable))
8875
- );
10208
+ const signature = await signingKey.sign(concat(NAMESPACE_SIGN_DOMAIN, signable));
8876
10209
  const unendorsed = new Uint8Array([0]);
8877
10210
  return hex(concat(signable, signature, unendorsed));
8878
10211
  }
8879
10212
 
8880
- // src/login/login.ts
8881
- var SIGN_DOMAIN5 = new TextEncoder().encode("calimero.auth.login.v1");
8882
- var AUDIENCE_TAG = {
8883
- webOrigin: 0,
8884
- codeSigningId: 1,
8885
- cli: 2
8886
- };
8887
- function audienceBody(audience) {
8888
- const encoder = new TextEncoder();
8889
- switch (audience.kind) {
8890
- case "webOrigin":
8891
- return encoder.encode(audience.origin);
8892
- case "codeSigningId":
8893
- return encoder.encode(audience.id);
8894
- case "cli":
8895
- return new Uint8Array(0);
8896
- }
8897
- }
8898
- function audienceSigningBytes(audience) {
8899
- return concat(
8900
- Uint8Array.from([AUDIENCE_TAG[audience.kind]]),
8901
- audienceBody(audience)
8902
- );
8903
- }
8904
- function audienceWire(audience) {
8905
- const tag2 = Uint8Array.from([AUDIENCE_TAG[audience.kind]]);
8906
- if (audience.kind === "cli") {
8907
- return tag2;
8908
- }
8909
- const body = audienceBody(audience);
8910
- return concat(tag2, u32le(body.length), body);
8911
- }
8912
- async function signLoginStatement(input) {
8913
- const node = fromHex(input.node, "node", 32);
8914
- const challenge = fromHex(input.challenge, "challenge", 32);
8915
- const sessionKey = fromHex(input.sessionKey, "sessionKey", 32);
8916
- const key = await importSigningKey(input.deviceSecret);
8917
- const deviceKey = await derivePublicKey(input.deviceSecret);
8918
- const issuedAt = u64le(input.issuedAt);
8919
- const expiresAt = u64le(input.expiresAt);
8920
- const preimage = await domainHash(SIGN_DOMAIN5, [
8921
- node,
8922
- audienceSigningBytes(input.audience),
8923
- challenge,
8924
- sessionKey,
8925
- deviceKey,
8926
- issuedAt,
8927
- expiresAt
8928
- ]);
8929
- const signature = new Uint8Array(
8930
- await crypto.subtle.sign({ name: "Ed25519" }, key, preimage)
8931
- );
8932
- return hex(
8933
- concat(
8934
- node,
8935
- audienceWire(input.audience),
8936
- challenge,
8937
- sessionKey,
8938
- deviceKey,
8939
- issuedAt,
8940
- expiresAt,
8941
- signature
8942
- )
8943
- );
8944
- }
8945
-
8946
- // src/login/session.ts
8947
- var DEFAULT_TTL_SECONDS2 = 300;
8948
- async function generateSessionKey() {
8949
- const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
8950
- return { publicKey: hex(await derivePublicKey(secret)), secret };
8951
- }
8952
- function audienceLabel(audience) {
8953
- switch (audience.kind) {
8954
- case "webOrigin":
8955
- return audience.origin;
8956
- case "codeSigningId":
8957
- return audience.id;
8958
- case "cli":
8959
- return "cli";
8960
- }
8961
- }
8962
- async function login(config) {
8963
- const baseUrl = config.nodeUrl.replace(/\/+$/, "");
8964
- const timeoutMs = config.timeoutMs ?? 1e4;
8965
- const request = async (path, init) => {
8966
- const controller = new AbortController();
8967
- const timer = setTimeout(() => controller.abort(), timeoutMs);
8968
- let response;
8969
- try {
8970
- const requestInit = { ...init, signal: controller.signal };
8971
- response = config.fetch ? await config.fetch(`${baseUrl}${path}`, requestInit) : await globalThis.fetch(`${baseUrl}${path}`, requestInit);
8972
- } catch (err) {
8973
- throw new HTTPError(
8974
- 0,
8975
- err instanceof Error ? err.name : "NetworkError",
8976
- `${baseUrl}${path}`,
8977
- new Headers(),
8978
- err instanceof Error ? err.message : String(err)
8979
- );
8980
- } finally {
8981
- clearTimeout(timer);
8982
- }
8983
- const text = await response.text().catch(() => "");
8984
- if (!response.ok) {
8985
- throw new HTTPError(
8986
- response.status,
8987
- response.statusText,
8988
- `${baseUrl}${path}`,
8989
- response.headers,
8990
- text
8991
- );
8992
- }
8993
- return text ? JSON.parse(text) : {};
8994
- };
8995
- const challengeBody = await request("/auth/challenge", {
8996
- method: "GET",
8997
- headers: { Accept: "application/json" }
8998
- });
8999
- const challenge = challengeBody.challenge ?? challengeBody.data?.challenge;
9000
- if (!challenge) {
9001
- throw new Error("the node issued no challenge");
9002
- }
9003
- const session = await generateSessionKey();
9004
- const issuedAt = Math.floor(Date.now() / 1e3);
9005
- const statement = await signLoginStatement({
9006
- node: config.node,
9007
- audience: config.audience,
9008
- challenge,
9009
- sessionKey: session.publicKey,
9010
- issuedAt,
9011
- expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS2),
9012
- deviceSecret: config.deviceSecret
9013
- });
9014
- const tokenBody = await request("/auth/token", {
9015
- method: "POST",
9016
- headers: { "Content-Type": "application/json", Accept: "application/json" },
9017
- body: JSON.stringify({
9018
- auth_method: "account_proof",
9019
- public_key: session.publicKey,
9020
- client_name: config.clientName ?? audienceLabel(config.audience),
9021
- timestamp: issuedAt,
9022
- provider_data: {
9023
- challenge,
9024
- login_statement: statement,
9025
- account_proof: config.accountProof
9026
- }
9027
- })
9028
- });
9029
- const accessToken = tokenBody.access_token ?? tokenBody.data?.access_token;
9030
- const refreshToken = tokenBody.refresh_token ?? tokenBody.data?.refresh_token;
9031
- if (!accessToken) {
9032
- throw new Error("the node minted no session");
9033
- }
9034
- return {
9035
- accessToken,
9036
- refreshToken: refreshToken ?? "",
9037
- sessionKey: session.publicKey,
9038
- sessionSecret: session.secret
9039
- };
9040
- }
9041
-
9042
10213
  // src/nodeDiscovery.ts
9043
10214
  var DEFAULT_LOCAL_NODE_PORTS = [2428, 2429, 2528, 2529];
9044
10215
  var LOCAL_HOST = "localhost";
@@ -9091,6 +10262,102 @@ async function discoverLocalNodes(options = {}) {
9091
10262
  );
9092
10263
  return results.filter((url) => url !== null);
9093
10264
  }
10265
+
10266
+ // src/relay-attestation/attest-node-key.ts
10267
+ var KEY_BINDING_DOMAIN = new TextEncoder().encode("calimero.tee-attest.key-binding.v1");
10268
+ var MOCK_QUOTE_HEADER = new TextEncoder().encode("MOCK_TDX_QUOTE_V1");
10269
+ var TDX_REPORT_DATA_OFFSET = 48 + 520;
10270
+ function concat2(...parts) {
10271
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
10272
+ let at = 0;
10273
+ for (const p of parts) {
10274
+ out.set(p, at);
10275
+ at += p.length;
10276
+ }
10277
+ return out;
10278
+ }
10279
+ function equal2(a, b) {
10280
+ if (a.length !== b.length) return false;
10281
+ let diff = 0;
10282
+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
10283
+ return diff === 0;
10284
+ }
10285
+ function fromBase643(b64) {
10286
+ const bin = atob(b64);
10287
+ const out = new Uint8Array(bin.length);
10288
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
10289
+ return out;
10290
+ }
10291
+ function fromHex32(value, label2) {
10292
+ if (typeof value !== "string" || !/^[0-9a-fA-F]{64}$/.test(value)) {
10293
+ throw new Error(`${label2} must be 64 hex characters`);
10294
+ }
10295
+ const out = new Uint8Array(32);
10296
+ for (let i = 0; i < 32; i += 1) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);
10297
+ return out;
10298
+ }
10299
+ async function attestKeyBinding(nodeKey) {
10300
+ const digest = await crypto.subtle.digest(
10301
+ "SHA-256",
10302
+ concat2(KEY_BINDING_DOMAIN, new Uint8Array(32), nodeKey)
10303
+ );
10304
+ return new Uint8Array(digest);
10305
+ }
10306
+ function reportDataOf(quote) {
10307
+ const mock = quote.length >= MOCK_QUOTE_HEADER.length && equal2(quote.subarray(0, MOCK_QUOTE_HEADER.length), MOCK_QUOTE_HEADER);
10308
+ const at = mock ? MOCK_QUOTE_HEADER.length : TDX_REPORT_DATA_OFFSET;
10309
+ if (quote.length < at + 64) {
10310
+ throw new Error(`the quote is too short to carry report data (${quote.length} bytes)`);
10311
+ }
10312
+ return { reportData: quote.slice(at, at + 64), mock };
10313
+ }
10314
+ async function attestRelayNodeKey(opts) {
10315
+ const nonce2 = crypto.getRandomValues(new Uint8Array(32));
10316
+ const url = `${opts.relayUrl.replace(/\/+$/, "")}/admin-api/tee/attest`;
10317
+ const body = JSON.stringify({
10318
+ nonce: hex(nonce2),
10319
+ bindNodeKey: true,
10320
+ ...opts.verify?.includeCollateral ? { includeCollateral: true } : {}
10321
+ });
10322
+ const init = { method: "POST", headers: { "Content-Type": "application/json" }, body };
10323
+ const response = opts.fetch ? await opts.fetch(url, init) : await globalThis.fetch(url, init);
10324
+ if (!response.ok) {
10325
+ const text = await response.text().catch(() => "");
10326
+ throw new Error(`the relay would not attest (HTTP ${response.status})${text ? `: ${text}` : ""}`);
10327
+ }
10328
+ const parsed = await response.json();
10329
+ const data = parsed.data ?? parsed;
10330
+ if (typeof data.quoteB64 !== "string") throw new Error("the relay returned no quote");
10331
+ const nodeKey = fromHex32(data.boundPublicKey, "boundPublicKey");
10332
+ const quote = fromBase643(data.quoteB64);
10333
+ const { reportData, mock } = reportDataOf(quote);
10334
+ if (mock) {
10335
+ if (!opts.allowMock) {
10336
+ throw new Error("the relay answered with a MOCK quote, which proves nothing about the hardware");
10337
+ }
10338
+ } else if (opts.verify) {
10339
+ const accepted = await opts.verify({
10340
+ quoteB64: data.quoteB64,
10341
+ nonce: hex(nonce2),
10342
+ reportDataSuffix: hex(await attestKeyBinding(nodeKey)),
10343
+ ...data.collateral ? { collateral: data.collateral } : {}
10344
+ });
10345
+ if (!accepted) {
10346
+ throw new Error("the relay's quote did not verify: not a trusted image, or not bound to this request");
10347
+ }
10348
+ } else if (opts.verifyQuote) {
10349
+ await opts.verifyQuote(quote);
10350
+ } else {
10351
+ throw new Error("a real TDX quote needs a verify (or verifyQuote) to check its signature and measurements");
10352
+ }
10353
+ if (!equal2(reportData.subarray(0, 32), nonce2)) {
10354
+ throw new Error("the quote does not carry our nonce: it was not made for this request");
10355
+ }
10356
+ if (!equal2(reportData.subarray(32, 64), await attestKeyBinding(nodeKey))) {
10357
+ throw new Error("the quote does not bind the key the relay named: refusing it");
10358
+ }
10359
+ return { nodeKey: hex(nodeKey), mock };
10360
+ }
9094
10361
  export {
9095
10362
  AdminApiClient,
9096
10363
  AuthApiClient,
@@ -9106,9 +10373,13 @@ export {
9106
10373
  IntentRefusedError,
9107
10374
  LocalStorageTokenStore,
9108
10375
  MemoryTokenStore,
10376
+ MeroClient,
9109
10377
  MeroJs,
9110
10378
  NODE_RELEASE_SIGNER,
10379
+ REQUEST_PROOF_HEADER,
9111
10380
  RelayClient,
10381
+ RelayObserver,
10382
+ RelayTransport,
9112
10383
  RpcClient,
9113
10384
  RpcError,
9114
10385
  SEALED_CONTENT_TYPE,
@@ -9117,21 +10388,36 @@ export {
9117
10388
  SealedTransportError,
9118
10389
  SseClient,
9119
10390
  StaleTransportKeyError,
10391
+ WarrantNonceExhaustedError,
10392
+ WarrantNonceRouteUnavailableError,
9120
10393
  WebHttpClient,
9121
10394
  WsClient,
9122
10395
  accountForRoot,
10396
+ accountForRootPublicKey,
9123
10397
  accountRootFromPhrase,
9124
10398
  accountRootFromSecret,
10399
+ accountRootSignerFromPhrase,
9125
10400
  admitterAddrsFromNetworkStatus,
10401
+ attestKeyBinding,
10402
+ attestRelayNodeKey,
10403
+ authorNonceLookup,
10404
+ beginCloudLink,
10405
+ beginDeviceEnrolment,
9126
10406
  buildAuthLoginUrl,
10407
+ callbackIsForeign,
9127
10408
  callerProof,
9128
10409
  capMask,
9129
10410
  capNames,
9130
10411
  classifyError,
10412
+ cloudLinkUrl,
9131
10413
  cloudNodeReleaseUrl,
9132
10414
  combineSignals,
9133
10415
  compareSemver,
10416
+ completeCloudLink,
10417
+ completeDeviceEnrolment,
9134
10418
  connectCloud,
10419
+ connectCloudWithAccount,
10420
+ createAccountRootSigner,
9135
10421
  createAdminApiClient,
9136
10422
  createAdminApiClientFromHttpClient,
9137
10423
  createAttestedSealedFetch,
@@ -9143,12 +10429,15 @@ export {
9143
10429
  createHttpClient,
9144
10430
  createLocalStorageNonceSource,
9145
10431
  createMemoryNonceSource,
10432
+ createMeroClient,
9146
10433
  createMeroJs,
9147
10434
  createNodeAdminApiClient,
9148
10435
  createNodeAuthApiClient,
9149
10436
  createNodeHttpClient,
10437
+ createProofAuthorizer,
9150
10438
  createProofSigner,
9151
10439
  createQuoteVerifier,
10440
+ createRecoveringNonceSource,
9152
10441
  createRetryableMethod,
9153
10442
  createSealedFetch,
9154
10443
  createSignedReleaseSealedFetch,
@@ -9156,8 +10445,10 @@ export {
9156
10445
  createTimeoutSignal,
9157
10446
  createUniversalHttpClient,
9158
10447
  creationInitHash,
10448
+ defaultAudience,
9159
10449
  defaultCapabilitiesSetOp,
9160
10450
  deviceCertPayload,
10451
+ deviceEnrolmentUrl,
9161
10452
  discoverLocalNodes,
9162
10453
  encodeSignedInvitation,
9163
10454
  fetchAttestedTransportKey,
@@ -9187,10 +10478,17 @@ export {
9187
10478
  nodeReleaseVersionFromImage,
9188
10479
  parseAuthCallback,
9189
10480
  parseCreationWarrant,
10481
+ parseDeviceCredential,
9190
10482
  parseGovernanceWarrant,
10483
+ parseWarrantNonce,
9191
10484
  probeNodeHealth,
10485
+ readCloudLinkCallback,
10486
+ readEnrolmentCallback,
9192
10487
  redeemInvitation,
10488
+ reportDataOf,
9193
10489
  requestBodyHash,
10490
+ resolveRoot,
10491
+ resolveRootPair,
9194
10492
  routingProofHeaders,
9195
10493
  shouldRetain,
9196
10494
  signAccountLink,
@@ -9201,13 +10499,16 @@ export {
9201
10499
  signLoginStatement,
9202
10500
  signMemberJoinOp,
9203
10501
  signRequest,
10502
+ signRequestProof,
9204
10503
  signRoutingChallenge,
9205
10504
  signWarrant,
10505
+ signedPath,
9206
10506
  signerFromCryptoKey,
9207
10507
  signerFromSecret,
9208
10508
  transportKeyBinding,
9209
10509
  trustSignedRelease,
9210
10510
  trustedMeasurementsFromReleases,
10511
+ verifyDeviceCredential,
9211
10512
  verifySignedNodeRelease,
9212
10513
  withCap,
9213
10514
  withRetry,