@calimero-network/mero-js 18.1.1 → 18.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -27,9 +27,11 @@ __export(index_exports, {
27
27
  DEFAULT_LOCAL_NODE_PORTS: () => DEFAULT_LOCAL_NODE_PORTS,
28
28
  EphemeralClient: () => EphemeralClient,
29
29
  HTTPError: () => HTTPError,
30
+ IntentRefusedError: () => IntentRefusedError,
30
31
  LocalStorageTokenStore: () => LocalStorageTokenStore,
31
32
  MemoryTokenStore: () => MemoryTokenStore,
32
33
  MeroJs: () => MeroJs,
34
+ RelayClient: () => RelayClient,
33
35
  RpcClient: () => RpcClient,
34
36
  RpcError: () => RpcError,
35
37
  SIGNED_NAMESPACE_OP_SCHEMA_VERSION: () => SIGNED_NAMESPACE_OP_SCHEMA_VERSION,
@@ -40,6 +42,7 @@ __export(index_exports, {
40
42
  buildAuthLoginUrl: () => buildAuthLoginUrl,
41
43
  combineSignals: () => combineSignals,
42
44
  compareSemver: () => compareSemver,
45
+ connectCloud: () => connectCloud,
43
46
  createAdminApiClient: () => createAdminApiClient,
44
47
  createAdminApiClientFromHttpClient: () => createAdminApiClientFromHttpClient,
45
48
  createAuthApiClient: () => createAuthApiClient,
@@ -48,6 +51,8 @@ __export(index_exports, {
48
51
  createBrowserAuthApiClient: () => createBrowserAuthApiClient,
49
52
  createBrowserHttpClient: () => createBrowserHttpClient,
50
53
  createHttpClient: () => createHttpClient,
54
+ createLocalStorageNonceSource: () => createLocalStorageNonceSource,
55
+ createMemoryNonceSource: () => createMemoryNonceSource,
51
56
  createMeroJs: () => createMeroJs,
52
57
  createNodeAdminApiClient: () => createNodeAdminApiClient,
53
58
  createNodeAuthApiClient: () => createNodeAuthApiClient,
@@ -963,8 +968,10 @@ var AdminApiClient = class {
963
968
  async getContextStorage(contextId) {
964
969
  return unwrap(await this.httpClient.get(`/admin-api/contexts/${contextId}/storage`));
965
970
  }
971
+ /** Sync one context, or every context when `contextId` is omitted. */
966
972
  async syncContext(contextId) {
967
- await this.httpClient.post(`/admin-api/contexts/sync/${contextId ?? ""}`, {});
973
+ const path = contextId ? `/admin-api/contexts/sync/${contextId}` : "/admin-api/contexts/sync";
974
+ await this.httpClient.post(path, {});
968
975
  }
969
976
  /**
970
977
  * Kick off a full state re-pull for a context (operator recovery for a
@@ -1179,6 +1186,32 @@ var AdminApiClient = class {
1179
1186
  )
1180
1187
  );
1181
1188
  }
1189
+ /**
1190
+ * Ask what this node can do for a member in `contextId`, before they sign
1191
+ * anything.
1192
+ *
1193
+ * The two facts {@link performIntent} needs and a client cannot derive: whose
1194
+ * account goes in the warrant's `executor`, and whether this node holds
1195
+ * `CAN_AUTHOR_ON_BEHALF` on the owning group. `canAuthorOnBehalf: false` comes
1196
+ * back as an answer rather than an error — it is the default state of every
1197
+ * context — so a caller can say "an admin of `groupId` must grant it to
1198
+ * `executorAccount`" instead of presenting a warrant that will be refused
1199
+ * after the author has spent a nonce on it.
1200
+ *
1201
+ * `404` means the context belongs to no group, or this node holds no account
1202
+ * yet; either way it can be named as no warrant's executor.
1203
+ *
1204
+ * For a caller with no credential on the node at all — a browser tab holding
1205
+ * one signing key — use `RelayClient`, which reads this same route on a node
1206
+ * run as a relay.
1207
+ */
1208
+ async getIntentRelay(contextId) {
1209
+ return unwrap(
1210
+ await this.httpClient.get(
1211
+ `/admin-api/contexts/${contextId}/intents`
1212
+ )
1213
+ );
1214
+ }
1182
1215
  async listNamespacesForApplication(applicationId) {
1183
1216
  return unwrap(await this.httpClient.get(`/admin-api/namespaces/for-application/${applicationId}`));
1184
1217
  }
@@ -2309,6 +2342,335 @@ var EphemeralClient = class {
2309
2342
  }
2310
2343
  };
2311
2344
 
2345
+ // src/cloud/cloud-client.ts
2346
+ var DEFAULT_CLOUD_BASE_URL = "https://cloud.calimero.network";
2347
+ var REFRESH_HEADER = "x-mdma-session-refresh";
2348
+ var REFRESH_EXPIRES_HEADER = "x-mdma-session-expires";
2349
+ var CloudClient = class {
2350
+ constructor(config = {}) {
2351
+ this.session = null;
2352
+ this.baseUrl = (config.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL).replace(/\/+$/, "");
2353
+ this.onSession = config.onSession;
2354
+ const injected = config.fetch;
2355
+ this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
2356
+ this.timeoutMs = config.timeoutMs ?? 1e4;
2357
+ if (config.sessionToken) {
2358
+ this.session = {
2359
+ sessionToken: config.sessionToken,
2360
+ expiresAt: 0,
2361
+ user: { email: "" }
2362
+ };
2363
+ }
2364
+ }
2365
+ /** The current session, or `null` when nobody is signed in. */
2366
+ getSession() {
2367
+ return this.session;
2368
+ }
2369
+ /** Whether a session token is held. Says nothing about the server agreeing. */
2370
+ isSignedIn() {
2371
+ return this.session !== null;
2372
+ }
2373
+ /**
2374
+ * Exchange a Google OAuth2 ID token for an MDMA session.
2375
+ *
2376
+ * The app owns the Google flow (One Tap, an auth-code popup, a native
2377
+ * browser) and hands the resulting ID token here once. Everything after this
2378
+ * uses the returned session token, so the Google credential does not have to
2379
+ * be kept or re-minted per call.
2380
+ */
2381
+ async signInWithGoogle(idToken) {
2382
+ const body = await this.request("POST", "/api/auth/google", { body: { id_token: idToken }, anonymous: true });
2383
+ return this.adoptSession(body);
2384
+ }
2385
+ /**
2386
+ * Refresh the session token explicitly.
2387
+ *
2388
+ * Rarely needed: MDMA rolling-refreshes on ordinary authenticated calls and
2389
+ * this client adopts the replacement automatically. Use it when an app wants
2390
+ * a fresh token before going idle rather than on the next request.
2391
+ */
2392
+ async refreshSession() {
2393
+ const body = await this.request("POST", "/api/auth/refresh");
2394
+ return this.adoptSession(body);
2395
+ }
2396
+ /**
2397
+ * Revoke this session server-side and forget it locally.
2398
+ *
2399
+ * Local state is cleared even if the server call fails: the caller asked to
2400
+ * be signed out, and keeping a token they believe is gone is the worse of the
2401
+ * two failures.
2402
+ */
2403
+ async signOut() {
2404
+ try {
2405
+ if (this.session) await this.request("POST", "/api/auth/logout", { parse: "none" });
2406
+ } finally {
2407
+ this.session = null;
2408
+ void this.notifySession(null);
2409
+ }
2410
+ }
2411
+ /** Namespaces the signed-in user owns. */
2412
+ async getMyNamespaces() {
2413
+ const rows = await this.request(
2414
+ "GET",
2415
+ "/api/cloud/me/namespaces"
2416
+ );
2417
+ return rows.map((row) => ({
2418
+ namespaceId: String(row.namespace_id ?? row.group_id ?? ""),
2419
+ groupId: String(row.group_id ?? row.namespace_id ?? ""),
2420
+ haStatus: String(row.ha_status ?? "none"),
2421
+ haEnabledAt: row.ha_enabled_at ?? null,
2422
+ contexts: Array.isArray(row.contexts) ? row.contexts : [],
2423
+ fleetReplicas: row.fleet_replicas
2424
+ }));
2425
+ }
2426
+ /**
2427
+ * The relays serving one namespace: where to present an intent, and as whom.
2428
+ *
2429
+ * Everything a client needs in order to mint a warrant it can actually
2430
+ * spend. Filter on `authorshipReady` before signing — a warrant presented to
2431
+ * a relay with no grant is refused *after* the author has burned a nonce from
2432
+ * its monotonic sequence on it.
2433
+ */
2434
+ async getNamespaceRelays(namespaceId) {
2435
+ const body = await this.request(
2436
+ "GET",
2437
+ `/api/cloud/me/namespaces/${encodeURIComponent(namespaceId)}/relays`
2438
+ );
2439
+ return (body.relays ?? []).map((row) => ({
2440
+ peerId: String(row.peer_id ?? ""),
2441
+ relayUrl: row.relay_url ?? null,
2442
+ executorAccount: row.executor_account ?? null,
2443
+ status: String(row.status ?? ""),
2444
+ authorshipReady: row.authorship_ready === true,
2445
+ lastSeenAt: row.last_seen_at ?? null,
2446
+ confirmedAt: row.confirmed_at ?? null
2447
+ }));
2448
+ }
2449
+ /**
2450
+ * The first relay for `namespaceId` that can actually run an intent, or
2451
+ * `null`.
2452
+ *
2453
+ * "Can actually run one" is three conditions, and a client that checks fewer
2454
+ * gets a refusal it cannot explain: the cloud must know a URL, the relay must
2455
+ * have reported the executor account a warrant has to name, and the grant
2456
+ * must be in place.
2457
+ */
2458
+ async findExecutingRelay(namespaceId) {
2459
+ const relays = await this.getNamespaceRelays(namespaceId);
2460
+ return relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ?? null;
2461
+ }
2462
+ /**
2463
+ * Every attested machine currently serving something this account owns.
2464
+ *
2465
+ * The account-wide view of the same rows {@link getNamespaceRelays} returns
2466
+ * per namespace, keyed by machine — so a UI can answer "what is running for
2467
+ * me?" in one call. `canExecute` folds the three-way precondition, so a
2468
+ * caller does not re-derive it.
2469
+ *
2470
+ * Only the caller's own namespaces appear on each machine. A plan's
2471
+ * `usersPerMachine` is greater than one, so a fleet node routinely serves
2472
+ * several accounts at once.
2473
+ */
2474
+ async getMyMachines() {
2475
+ const body = await this.request(
2476
+ "GET",
2477
+ "/api/cloud/me/machines"
2478
+ );
2479
+ return (body.machines ?? []).map((row) => ({
2480
+ peerId: String(row.peer_id ?? ""),
2481
+ relayUrl: row.relay_url ?? null,
2482
+ executorAccount: row.executor_account ?? null,
2483
+ canExecute: row.can_execute === true,
2484
+ namespaces: (Array.isArray(row.namespaces) ? row.namespaces : []).map((n) => {
2485
+ const ns = n;
2486
+ return {
2487
+ namespaceId: String(ns.namespace_id ?? ""),
2488
+ status: String(ns.status ?? ""),
2489
+ authorshipReady: ns.authorship_ready === true,
2490
+ fresh: ns.fresh === true,
2491
+ confirmedAt: ns.confirmed_at ?? null,
2492
+ lastSeenAt: ns.last_seen_at ?? null
2493
+ };
2494
+ })
2495
+ }));
2496
+ }
2497
+ /**
2498
+ * Claim a namespace under this account, proving ownership.
2499
+ *
2500
+ * The cloud has no other way to learn a namespace exists — namespaces are
2501
+ * created on a node, and there is deliberately no "create namespace" in the
2502
+ * cloud. `ownershipProof` comes from
2503
+ * `admin.issueNamespaceOwnershipProof(namespaceId, …)` on a node that is a
2504
+ * **direct admin** of that namespace; merod refuses to issue one otherwise.
2505
+ *
2506
+ * Pass merod's response object straight through. The cloud accepts core's
2507
+ * `signerPublicKey`/`signedPayload` casing as well as snake_case, so there is
2508
+ * no re-keying step — and getting that wrong used to surface as a `422`
2509
+ * naming three missing fields.
2510
+ *
2511
+ * Idempotent for the same account. A namespace already claimed by someone
2512
+ * else is a `409`; a replayed proof nonce is a `403`.
2513
+ */
2514
+ async claimNamespace(namespaceId, ownershipProof) {
2515
+ await this.request("POST", "/api/cloud/namespaces/claim", {
2516
+ body: { namespace_id: namespaceId, ownership_proof: ownershipProof }
2517
+ });
2518
+ }
2519
+ /**
2520
+ * Ask the fleet to host a claimed namespace — the step that gets a relay
2521
+ * assigned.
2522
+ *
2523
+ * Takes no body: {@link claimNamespace} already established ownership, and
2524
+ * this is the separate "now host it" half. Idempotent.
2525
+ *
2526
+ * Not to be confused with {@link enableHA}, which opens the cloud's web flow
2527
+ * in a browser tab for a user to complete by hand. This is the API call.
2528
+ */
2529
+ async enableNamespaceHa(namespaceId) {
2530
+ return this.request(
2531
+ "POST",
2532
+ `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/enable-ha`,
2533
+ { body: {} }
2534
+ );
2535
+ }
2536
+ /**
2537
+ * Stop hosting a claimed namespace.
2538
+ *
2539
+ * The fleet's own reconcile loop does the rest: each assigned node notices
2540
+ * the namespace is no longer assigned to it and **self-leaves**, which makes
2541
+ * core evict it and purge its local data and keys. So this is not just a
2542
+ * billing flip.
2543
+ */
2544
+ async disableNamespaceHa(namespaceId) {
2545
+ return this.request(
2546
+ "POST",
2547
+ `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/disable-ha`,
2548
+ { body: {} }
2549
+ );
2550
+ }
2551
+ /**
2552
+ * The node assigned to the signed-in user, or `null`.
2553
+ *
2554
+ * @deprecated Use {@link getMyMachines}. This reads the cloud's retired
2555
+ * `NodeAssignment` ledger, which nothing has written since the v1
2556
+ * retirement, so it answers `null` for every namespace-native account —
2557
+ * which reads as "you have no machine" when the truth is "that is no longer
2558
+ * how a machine is assigned".
2559
+ */
2560
+ async getMyNode() {
2561
+ const body = await this.request(
2562
+ "GET",
2563
+ "/api/cloud/me/node"
2564
+ );
2565
+ return body.node ?? null;
2566
+ }
2567
+ enableHA(options) {
2568
+ const params = new URLSearchParams({
2569
+ group_id: options.groupId,
2570
+ context_id: options.contextId
2571
+ });
2572
+ if (options.redirectUrl) {
2573
+ params.set("redirect_url", options.redirectUrl);
2574
+ }
2575
+ window.open(`${this.baseUrl}/enable-ha?${params.toString()}`);
2576
+ }
2577
+ disableHA(options) {
2578
+ const params = new URLSearchParams({
2579
+ group_id: options.groupId,
2580
+ context_id: options.contextId
2581
+ });
2582
+ if (options.redirectUrl) {
2583
+ params.set("redirect_url", options.redirectUrl);
2584
+ }
2585
+ window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
2586
+ }
2587
+ /** Record a freshly-issued session and tell the app to persist it. */
2588
+ adoptSession(body) {
2589
+ this.session = {
2590
+ sessionToken: body.session_token,
2591
+ expiresAt: body.expires_at,
2592
+ user: body.user
2593
+ };
2594
+ void this.notifySession(this.session);
2595
+ return this.session;
2596
+ }
2597
+ async notifySession(session) {
2598
+ try {
2599
+ await this.onSession?.(session);
2600
+ } catch {
2601
+ }
2602
+ }
2603
+ async request(method, path, options = {}) {
2604
+ if (!options.anonymous && !this.session) {
2605
+ throw new Error(
2606
+ "Not signed in to Calimero Cloud: call signInWithGoogle() or construct the client with a sessionToken"
2607
+ );
2608
+ }
2609
+ const headers = { Accept: "application/json" };
2610
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
2611
+ if (!options.anonymous && this.session) {
2612
+ headers.Authorization = `Bearer ${this.session.sessionToken}`;
2613
+ }
2614
+ const controller = new AbortController();
2615
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
2616
+ let response;
2617
+ try {
2618
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
2619
+ method,
2620
+ headers,
2621
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
2622
+ signal: controller.signal
2623
+ });
2624
+ } catch (err) {
2625
+ throw new HTTPError(
2626
+ 0,
2627
+ err instanceof Error ? err.name : "NetworkError",
2628
+ `${this.baseUrl}${path}`,
2629
+ new Headers(),
2630
+ err instanceof Error ? err.message : String(err)
2631
+ );
2632
+ } finally {
2633
+ clearTimeout(timer);
2634
+ }
2635
+ this.adoptRollingRefresh(response);
2636
+ if (!response.ok) {
2637
+ const bodyText = await response.text().catch(() => void 0);
2638
+ throw new HTTPError(
2639
+ response.status,
2640
+ response.statusText,
2641
+ `${this.baseUrl}${path}`,
2642
+ response.headers,
2643
+ bodyText
2644
+ );
2645
+ }
2646
+ if (options.parse === "none" || response.status === 204) return void 0;
2647
+ return await response.json();
2648
+ }
2649
+ /**
2650
+ * Adopt a session token the server refreshed mid-request.
2651
+ *
2652
+ * MDMA rotates the token on activity and returns the replacement in a
2653
+ * response header rather than making the client ask. Ignoring it costs
2654
+ * nothing immediately and then logs the user out a week later, which is the
2655
+ * kind of bug that is impossible to reproduce in a short session.
2656
+ *
2657
+ * The user identity is carried over: the header holds only a token, and the
2658
+ * refresh is the same logical session as the sign-in that named the user.
2659
+ */
2660
+ adoptRollingRefresh(response) {
2661
+ const token = response.headers.get(REFRESH_HEADER);
2662
+ if (!token || !this.session) return;
2663
+ const expiresRaw = response.headers.get(REFRESH_EXPIRES_HEADER);
2664
+ const expiresAt = Number(expiresRaw);
2665
+ this.session = {
2666
+ sessionToken: token,
2667
+ expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : this.session.expiresAt,
2668
+ user: this.session.user
2669
+ };
2670
+ void this.notifySession(this.session);
2671
+ }
2672
+ };
2673
+
2312
2674
  // src/mero-js.ts
2313
2675
  var REFRESH_LOCK_NAME = "mero-js:token-refresh";
2314
2676
  function getLockManager() {
@@ -2442,6 +2804,22 @@ var MeroJs = class {
2442
2804
  }
2443
2805
  return this.ephemeralClient;
2444
2806
  }
2807
+ /**
2808
+ * Calimero Cloud (lazy initialized).
2809
+ *
2810
+ * Available whether or not `cloud` was configured — an app that signs in
2811
+ * later gets an unauthenticated client it can call `signInWithGoogle` on,
2812
+ * which is one fewer construction path than making the getter conditional.
2813
+ * It is entirely independent of this instance's node credential: the cloud
2814
+ * knows nothing about the node's tokens and the node knows nothing about the
2815
+ * cloud session.
2816
+ */
2817
+ get cloud() {
2818
+ if (!this.cloudClient) {
2819
+ this.cloudClient = new CloudClient(this.config.cloud ?? {});
2820
+ }
2821
+ return this.cloudClient;
2822
+ }
2445
2823
  /**
2446
2824
  * Get the WebSocket event client (lazy initialized).
2447
2825
  * @experimental Use `events` (SSE) for production. WsClient is experimental.
@@ -2686,33 +3064,6 @@ var LocalStorageTokenStore = class {
2686
3064
  }
2687
3065
  };
2688
3066
 
2689
- // src/cloud/cloud-client.ts
2690
- var CloudClient = class {
2691
- constructor(config = {}) {
2692
- this.baseUrl = (config.cloudBaseUrl || "https://cloud.calimero.network").replace(/\/+$/, "");
2693
- }
2694
- enableHA(options) {
2695
- const params = new URLSearchParams({
2696
- group_id: options.groupId,
2697
- context_id: options.contextId
2698
- });
2699
- if (options.redirectUrl) {
2700
- params.set("redirect_url", options.redirectUrl);
2701
- }
2702
- window.open(`${this.baseUrl}/enable-ha?${params.toString()}`);
2703
- }
2704
- disableHA(options) {
2705
- const params = new URLSearchParams({
2706
- group_id: options.groupId,
2707
- context_id: options.contextId
2708
- });
2709
- if (options.redirectUrl) {
2710
- params.set("redirect_url", options.redirectUrl);
2711
- }
2712
- window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
2713
- }
2714
- };
2715
-
2716
3067
  // src/crypto/internal.ts
2717
3068
  var PKCS8_ED25519_PREFIX = new Uint8Array([
2718
3069
  48,
@@ -2854,6 +3205,245 @@ async function signWarrant(input) {
2854
3205
  );
2855
3206
  }
2856
3207
 
3208
+ // src/relay/relay-client.ts
3209
+ var DEFAULT_TTL_SECONDS = 300;
3210
+ var IntentRefusedError = class extends Error {
3211
+ constructor(reason, retryable, status) {
3212
+ super(`relay refused the intent (HTTP ${status}): ${reason}`);
3213
+ this.reason = reason;
3214
+ this.retryable = retryable;
3215
+ this.status = status;
3216
+ this.name = "IntentRefusedError";
3217
+ }
3218
+ };
3219
+ var RelayClient = class {
3220
+ constructor(config) {
3221
+ this.config = config;
3222
+ this.baseUrl = config.relayUrl.replace(/\/+$/, "");
3223
+ this.executorAccount = config.executorAccount;
3224
+ const injected = config.fetch;
3225
+ this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
3226
+ this.timeoutMs = config.timeoutMs ?? 1e4;
3227
+ }
3228
+ /**
3229
+ * Ask the relay what it can do in `contextId`.
3230
+ *
3231
+ * Cheap and unauthenticated on a relay that serves delegated execution
3232
+ * publicly, which is what makes it usable as a precondition check rather than
3233
+ * as diagnostics after a failure.
3234
+ */
3235
+ async describe(contextId) {
3236
+ const body = await this.json("GET", `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`);
3237
+ this.executorAccount = body.data.executorAccount;
3238
+ return body.data;
3239
+ }
3240
+ /**
3241
+ * Mint a warrant for `method(args)` and present it to the relay.
3242
+ *
3243
+ * One intent, once: the warrant authorizes exactly this method and these
3244
+ * arguments in exactly this context, and its nonce is spent by the network on
3245
+ * apply. Nothing accumulates and nothing is reusable.
3246
+ */
3247
+ async execute(contextId, method, argsJson = {}) {
3248
+ const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
3249
+ const nonce = await this.config.nonces.next();
3250
+ const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
3251
+ const warrant = await signWarrant({
3252
+ context: contextId,
3253
+ authorAccount: this.config.authorAccount,
3254
+ executor,
3255
+ method,
3256
+ argsJson,
3257
+ nonce,
3258
+ notAfter: BigInt(Math.floor(Date.now() / 1e3)) + BigInt(ttl),
3259
+ deviceSecret: this.config.deviceSecret
3260
+ });
3261
+ const body = await this.json(
3262
+ "POST",
3263
+ `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`,
3264
+ { method, argsJson, warrant, authorProof: this.config.authorProof }
3265
+ );
3266
+ return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
3267
+ }
3268
+ async json(method, path, body) {
3269
+ const headers = { Accept: "application/json" };
3270
+ if (body !== void 0) headers["Content-Type"] = "application/json";
3271
+ const controller = new AbortController();
3272
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
3273
+ let response;
3274
+ try {
3275
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
3276
+ method,
3277
+ headers,
3278
+ body: body === void 0 ? void 0 : JSON.stringify(body),
3279
+ signal: controller.signal
3280
+ });
3281
+ } catch (err) {
3282
+ throw new HTTPError(
3283
+ 0,
3284
+ err instanceof Error ? err.name : "NetworkError",
3285
+ `${this.baseUrl}${path}`,
3286
+ new Headers(),
3287
+ err instanceof Error ? err.message : String(err)
3288
+ );
3289
+ } finally {
3290
+ clearTimeout(timer);
3291
+ }
3292
+ if (!response.ok) {
3293
+ const text = await response.text().catch(() => "");
3294
+ if (response.status === 400 || response.status === 403) {
3295
+ const reason = extractReason(text);
3296
+ throw new IntentRefusedError(reason, /nonce/i.test(reason), response.status);
3297
+ }
3298
+ throw new HTTPError(
3299
+ response.status,
3300
+ response.statusText,
3301
+ `${this.baseUrl}${path}`,
3302
+ response.headers,
3303
+ text
3304
+ );
3305
+ }
3306
+ return await response.json();
3307
+ }
3308
+ };
3309
+ function extractReason(bodyText) {
3310
+ if (!bodyText) return "no reason given";
3311
+ try {
3312
+ const parsed = JSON.parse(bodyText);
3313
+ for (const candidate of [parsed.error, parsed.message]) {
3314
+ if (typeof candidate === "string" && candidate) return candidate;
3315
+ }
3316
+ } catch {
3317
+ }
3318
+ return bodyText;
3319
+ }
3320
+
3321
+ // src/relay/nonce-source.ts
3322
+ function createMemoryNonceSource(start = 1) {
3323
+ let next = BigInt(start);
3324
+ if (next < 1n) {
3325
+ throw new Error("nonce sequence starts at 1");
3326
+ }
3327
+ return {
3328
+ next() {
3329
+ const value = next;
3330
+ next += 1n;
3331
+ return Promise.resolve(value);
3332
+ }
3333
+ };
3334
+ }
3335
+ function createLocalStorageNonceSource(key, storage) {
3336
+ const store = storage ?? globalThis.localStorage;
3337
+ if (!store) {
3338
+ throw new Error(
3339
+ "no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence"
3340
+ );
3341
+ }
3342
+ return {
3343
+ next() {
3344
+ const raw = store.getItem(key);
3345
+ let current = 0n;
3346
+ if (raw !== null && raw !== "") {
3347
+ try {
3348
+ const parsed = BigInt(raw);
3349
+ if (parsed > 0n) current = parsed;
3350
+ } catch {
3351
+ current = 0n;
3352
+ }
3353
+ }
3354
+ const value = current + 1n;
3355
+ store.setItem(key, value.toString());
3356
+ return Promise.resolve(value);
3357
+ }
3358
+ };
3359
+ }
3360
+
3361
+ // src/cloud/connect.ts
3362
+ async function connectCloud(options) {
3363
+ if (!options.sessionToken && !options.googleIdToken) {
3364
+ throw new Error("connectCloud needs either a sessionToken or a googleIdToken");
3365
+ }
3366
+ const cloud = new CloudClient({
3367
+ cloudBaseUrl: options.cloudBaseUrl,
3368
+ sessionToken: options.sessionToken,
3369
+ onSession: options.onSession,
3370
+ fetch: options.fetch,
3371
+ timeoutMs: options.timeoutMs
3372
+ });
3373
+ if (!options.sessionToken && options.googleIdToken) {
3374
+ await cloud.signInWithGoogle(options.googleIdToken);
3375
+ }
3376
+ const namespaces = await cloud.getMyNamespaces();
3377
+ const namespace = pickNamespace(namespaces, options.namespaceId);
3378
+ const relays = await cloud.getNamespaceRelays(namespace.namespaceId);
3379
+ const relayInfo = relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ?? null;
3380
+ if (!relayInfo) {
3381
+ throw new Error(explainNoRelay(namespace, relays));
3382
+ }
3383
+ const relay = new RelayClient({
3384
+ // Checked by `findExecutingRelay`, which is the only thing that returns a
3385
+ // relay — narrowed here rather than re-validated, so the invariant lives in
3386
+ // one place.
3387
+ relayUrl: relayInfo.relayUrl,
3388
+ executorAccount: relayInfo.executorAccount,
3389
+ authorAccount: options.authorAccount,
3390
+ authorProof: options.authorProof,
3391
+ deviceSecret: options.deviceSecret,
3392
+ nonces: options.nonces ?? await defaultNonceSource(options.deviceSecret),
3393
+ ttlSeconds: options.ttlSeconds,
3394
+ fetch: options.fetch,
3395
+ timeoutMs: options.timeoutMs
3396
+ });
3397
+ return {
3398
+ cloud,
3399
+ relay,
3400
+ namespaceId: namespace.namespaceId,
3401
+ namespace,
3402
+ relayInfo,
3403
+ execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson)
3404
+ };
3405
+ }
3406
+ function pickNamespace(namespaces, requested) {
3407
+ if (requested) {
3408
+ const found = namespaces.find((n) => n.namespaceId === requested);
3409
+ if (!found) {
3410
+ throw new Error(
3411
+ `this account owns no namespace ${requested}; it owns: ${namespaces.map((n) => n.namespaceId).join(", ") || "(none)"}`
3412
+ );
3413
+ }
3414
+ return found;
3415
+ }
3416
+ if (namespaces.length === 0) {
3417
+ throw new Error(
3418
+ "this account owns no namespaces in the cloud: claim one first, then enable HA on it so a relay is assigned"
3419
+ );
3420
+ }
3421
+ if (namespaces.length > 1) {
3422
+ throw new Error(
3423
+ `this account owns ${namespaces.length} namespaces \u2014 pass namespaceId to say which: ${namespaces.map((n) => n.namespaceId).join(", ")}`
3424
+ );
3425
+ }
3426
+ return namespaces[0];
3427
+ }
3428
+ function explainNoRelay(namespace, relays) {
3429
+ if (relays.length === 0) {
3430
+ return namespace.haStatus === "enabled" ? `namespace ${namespace.namespaceId} has HA enabled but no relay has been assigned to it yet \u2014 retry shortly` : `namespace ${namespace.namespaceId} has no relays: enable HA on it in the cloud to have an attested node assigned`;
3431
+ }
3432
+ const waitingOnGrant = relays.filter((r) => !r.authorshipReady);
3433
+ if (waitingOnGrant.length === relays.length) {
3434
+ return `every relay on namespace ${namespace.namespaceId} is waiting for the authorship grant: an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(", ") || "the relay account"}`;
3435
+ }
3436
+ return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none reported both a URL and an executor account yet \u2014 retry shortly`;
3437
+ }
3438
+ async function defaultNonceSource(deviceSecret) {
3439
+ const key = `mero-js:warrant-nonce:${hex(await derivePublicKey(deviceSecret))}`;
3440
+ try {
3441
+ return createLocalStorageNonceSource(key);
3442
+ } catch {
3443
+ return createMemoryNonceSource(1);
3444
+ }
3445
+ }
3446
+
2857
3447
  // src/device-cert/device-cert.ts
2858
3448
  var CERT_DOMAIN = new TextEncoder().encode("calimero.device.cert.v1");
2859
3449
  var ACCOUNT_ID_DOMAIN = new TextEncoder().encode("calimero.account.genesis.v1");
@@ -3050,7 +3640,19 @@ var CAPABILITIES = {
3050
3640
  CAN_CREATE_SUBGROUP: 1 << 5,
3051
3641
  CAN_DELETE_SUBGROUP: 1 << 6,
3052
3642
  CAN_MANAGE_VISIBILITY: 1 << 7,
3053
- CAN_MANAGE_METADATA: 1 << 8
3643
+ CAN_MANAGE_METADATA: 1 << 8,
3644
+ /**
3645
+ * Publish writes attributed to ANOTHER member, under a warrant that member
3646
+ * signed — the grant delegated execution runs on.
3647
+ *
3648
+ * Implied by nothing: not by membership, not by admin, and not propagated by
3649
+ * the subgroup-admit cascade. So every group is authorship-closed until an
3650
+ * admin sets this bit, and a relay without it is refused at
3651
+ * `performIntent` before anything executes. That is why it is worth naming
3652
+ * here rather than leaving callers to write `1 << 9`: a client granting it
3653
+ * has to be able to say what it is granting.
3654
+ */
3655
+ CAN_AUTHOR_ON_BEHALF: 1 << 9
3054
3656
  };
3055
3657
  function hasCap(mask, cap) {
3056
3658
  const capU32 = cap >>> 0;