@calimero-network/mero-js 22.7.0 → 22.9.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 (76) 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 +154 -2
  6. package/dist/admin-api/admin-client.js +255 -1
  7. package/dist/admin-api/admin-types.d.ts +193 -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 +96 -2
  21. package/dist/device-cert/device-cert.js +101 -11
  22. package/dist/device-cert/device-scope.d.ts +58 -0
  23. package/dist/device-cert/device-scope.js +76 -0
  24. package/dist/device-cert/index.d.ts +4 -2
  25. package/dist/device-cert/index.js +2 -1
  26. package/dist/events/sse.d.ts +22 -2
  27. package/dist/events/sse.js +20 -11
  28. package/dist/http-client/http-types.d.ts +18 -0
  29. package/dist/http-client/web-client.js +21 -1
  30. package/dist/index.browser.mjs +2 -2
  31. package/dist/index.browser.mjs.map +4 -4
  32. package/dist/index.cjs +1955 -496
  33. package/dist/index.cjs.map +4 -4
  34. package/dist/index.d.ts +13 -4
  35. package/dist/index.js +21 -2
  36. package/dist/index.mjs +1955 -496
  37. package/dist/index.mjs.map +4 -4
  38. package/dist/invitation/index.d.ts +2 -0
  39. package/dist/invitation/index.js +2 -0
  40. package/dist/invitation/invitation.d.ts +106 -0
  41. package/dist/invitation/invitation.js +104 -0
  42. package/dist/login/login.d.ts +11 -1
  43. package/dist/login/login.js +5 -4
  44. package/dist/login/session.d.ts +21 -5
  45. package/dist/login/session.js +7 -15
  46. package/dist/namespace-op/namespace-op.d.ts +14 -2
  47. package/dist/namespace-op/namespace-op.js +13 -19
  48. package/dist/relay/index.d.ts +2 -2
  49. package/dist/relay/index.js +1 -1
  50. package/dist/relay/nonce-source.d.ts +135 -0
  51. package/dist/relay/nonce-source.js +187 -19
  52. package/dist/relay/relay-client.d.ts +60 -5
  53. package/dist/relay/relay-client.js +84 -4
  54. package/dist/relay-attestation/attest-node-key.d.ts +66 -0
  55. package/dist/relay-attestation/attest-node-key.js +110 -0
  56. package/dist/relay-attestation/index.d.ts +1 -0
  57. package/dist/relay-attestation/index.js +1 -0
  58. package/dist/request-proof/authorizer.d.ts +48 -0
  59. package/dist/request-proof/authorizer.js +65 -0
  60. package/dist/request-proof/index.d.ts +4 -0
  61. package/dist/request-proof/index.js +2 -0
  62. package/dist/request-proof/request-proof.d.ts +56 -0
  63. package/dist/request-proof/request-proof.js +111 -0
  64. package/dist/rpc/index.d.ts +21 -0
  65. package/dist/rpc/index.js +20 -0
  66. package/dist/transport/index.d.ts +6 -0
  67. package/dist/transport/index.js +3 -0
  68. package/dist/transport/mero-client.d.ts +286 -0
  69. package/dist/transport/mero-client.js +379 -0
  70. package/dist/transport/relay-observer.d.ts +152 -0
  71. package/dist/transport/relay-observer.js +173 -0
  72. package/dist/transport/relay-transport.d.ts +73 -0
  73. package/dist/transport/relay-transport.js +94 -0
  74. package/dist/transport/types.d.ts +100 -0
  75. package/dist/transport/types.js +32 -0
  76. package/package.json +1 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A device cert, as something every transport can authenticate a request with.
3
+ *
4
+ * {@link signRequestProof} signs one request. This wraps it in the shape the
5
+ * rest of the SDK can consume without knowing what a proof is: a function from
6
+ * `(method, path, body)` to the headers that request should carry. The HTTP
7
+ * client, the SSE client and the admin client each take that function, so a
8
+ * keyholder is authenticated the same way on all three and none of them holds a
9
+ * second copy of the encoding.
10
+ *
11
+ * ## Why the authorizer, and not a token
12
+ *
13
+ * A bearer token is one value reused across requests, so `getAuthToken` can be
14
+ * called once and its result cached. A proof commits to the method, the path and
15
+ * the body, so it cannot be: every request needs its own, and the signature has
16
+ * to be taken where those three are known. That is the whole reason this is a
17
+ * per-request callback rather than another token provider.
18
+ *
19
+ * Measured against a node built from core `aecba573b`: reads answer `200` to a
20
+ * proof with no session, `/sse` answers `200` and `/ws` upgrades `101`, and the
21
+ * listings are caller-scoped — an account in one of the node's two namespaces is
22
+ * shown one, and a WS subscribe naming a context it is not a member of is
23
+ * refused while the one it is a member of is granted.
24
+ */
25
+ import { REQUEST_PROOF_HEADER, signRequestProof } from './request-proof.js';
26
+ /**
27
+ * Turn a device cert into a {@link RequestAuthorizer}.
28
+ *
29
+ * The credential is validated once, here, rather than on the first request:
30
+ * a malformed one otherwise surfaces as a 401 from whichever read happened to
31
+ * run first, which reads as "the node refused me" and sends the caller looking
32
+ * in the wrong place.
33
+ */
34
+ export function createProofAuthorizer(credential) {
35
+ if (!credential.credential) {
36
+ throw new Error('a proof authorizer needs the `credential` from signDeviceCert');
37
+ }
38
+ if (!credential.deviceSecret && !credential.signer) {
39
+ throw new Error('a proof authorizer needs either `deviceSecret` or `signer`');
40
+ }
41
+ return async ({ method, path, body }) => {
42
+ const proof = await signRequestProof({
43
+ method,
44
+ path,
45
+ body,
46
+ credential: credential.credential,
47
+ deviceSecret: credential.deviceSecret,
48
+ signer: credential.signer,
49
+ validForSeconds: credential.validForSeconds,
50
+ });
51
+ return { [REQUEST_PROOF_HEADER]: proof };
52
+ };
53
+ }
54
+ /**
55
+ * The path a proof must be signed over, taken from a URL.
56
+ *
57
+ * Callers hold a base URL and a relative path; the proof commits to the path the
58
+ * node will see, which is their join with the query dropped. Deriving it from
59
+ * the assembled URL rather than concatenating strings is what keeps a base URL
60
+ * carrying its own prefix (`…/admin-api`) from signing a path the node never
61
+ * receives.
62
+ */
63
+ export function signedPath(url) {
64
+ return new URL(url).pathname;
65
+ }
@@ -0,0 +1,4 @@
1
+ export { signRequestProof, REQUEST_PROOF_HEADER } from './request-proof.js';
2
+ export type { RequestProofInput } from './request-proof.js';
3
+ export { createProofAuthorizer, signedPath } from './authorizer.js';
4
+ export type { ProofCredential, RequestAuthorizer, RequestToAuthorize } from './authorizer.js';
@@ -0,0 +1,2 @@
1
+ export { signRequestProof, REQUEST_PROOF_HEADER } from './request-proof.js';
2
+ export { createProofAuthorizer, signedPath } from './authorizer.js';
@@ -0,0 +1,56 @@
1
+ import type { Signer } from '../signer/index.js';
2
+ /** The header a signed request carries. */
3
+ export declare const REQUEST_PROOF_HEADER = "X-Calimero-Proof";
4
+ export interface RequestProofInput {
5
+ /**
6
+ * The HTTP method, exactly as it will be sent.
7
+ *
8
+ * Not folded. `HEAD` and `GET` are the same *permission* and the node's
9
+ * permission table treats them so, but they are different *requests*: folding
10
+ * them here would let a proof minted for one be presented as the other.
11
+ */
12
+ method: string;
13
+ /**
14
+ * The path, **without** the query string.
15
+ *
16
+ * Excluded because a proxy may legitimately rewrite a query, and a signature
17
+ * over bytes something else may change fails for reasons the caller cannot
18
+ * see. Anything that must be bound belongs in the body.
19
+ */
20
+ path: string;
21
+ /**
22
+ * The body, exactly as it will be sent. Omit when there is none.
23
+ *
24
+ * There is no "absent body" case: an omitted body commits to the hash of no
25
+ * bytes, so a signer and a verifier cannot disagree about which encoding a
26
+ * bodyless request used.
27
+ */
28
+ body?: string | Uint8Array;
29
+ /** The `AccountProof<DeviceCert>` from `signDeviceCert`, hex. */
30
+ credential: string;
31
+ /** The certified device's ed25519 secret, hex. Or pass `signer`. */
32
+ deviceSecret?: string;
33
+ /** A signer, for a key that cannot be exported to hex. */
34
+ signer?: Signer;
35
+ /** Seconds the signature stays valid. Defaults to 300. */
36
+ validForSeconds?: number;
37
+ /** Override the clock, for tests. Unix seconds. */
38
+ now?: number;
39
+ }
40
+ /**
41
+ * Sign one request and return the `X-Calimero-Proof` value, hex.
42
+ *
43
+ * The encoding is borsh, and it is assembled rather than re-derived: the
44
+ * account proof is the **first** field of `CallerProof`, so the credential hex
45
+ * a caller already holds is exactly its own prefix. Concatenating avoids a
46
+ * second implementation of a structure the node deserializes — the class of bug
47
+ * that surfaces far away, as a node refusing a credential nothing can explain.
48
+ *
49
+ * CallerProof = <account_proof bytes> || 0x00 (no session) || RequestSig
50
+ * RequestSig = u32le(len method) || method
51
+ * || u32le(len path) || path
52
+ * || body_hash[32]
53
+ * || u64le(issued_at) || u64le(expires_at)
54
+ * || signature[64]
55
+ */
56
+ export declare function signRequestProof(input: RequestProofInput): Promise<string>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Signing one request, so a caller's identity travels with it.
3
+ *
4
+ * This is the credential a keyholder presents when it has no session: the device
5
+ * key signs the request itself, and the certificate its account signed says whose
6
+ * key that is. The node verifies both with no store read and no prior
7
+ * relationship — `crates/server/src/proof_auth.rs` — which is what lets a hosted
8
+ * relay serve a caller it has never seen and holds no auth provider for.
9
+ *
10
+ * ## Why this exists alongside `login()`
11
+ *
12
+ * A session is a second link in the same chain, not an alternative to it. It
13
+ * earns its place where the device key sits behind a boundary worth crossing
14
+ * once — a hardware key, an extension — so a per-call signature becomes a
15
+ * per-session one. A CLI, a server job, or a browser holding its device secret
16
+ * in memory gains nothing from it, and `login()` additionally needs the node's
17
+ * signing key learned out of band. This path needs neither.
18
+ *
19
+ * Measured against a node built from core `aecba573b`: a two-link proof is
20
+ * served `200` on `/admin-api/namespaces`, `/contexts`, `/contexts/{id}`,
21
+ * `/groups/{id}`, `/groups/{id}/contexts`, `/contexts/{id}/identities` and
22
+ * `/storage`, and the listings are **filtered** — an account in one of a node's
23
+ * two namespaces is shown one. `POST /admin-api/namespaces` is refused `403`, so
24
+ * this authenticates a caller without granting operator actions.
25
+ */
26
+ import { concat, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
27
+ const utf8 = (text) => new TextEncoder().encode(text);
28
+ /** `calimero.auth.request.v1` — what the request signature is separated by. */
29
+ const REQUEST_SIGN_DOMAIN = utf8('calimero.auth.request.v1');
30
+ /**
31
+ * `calimero.auth.request.body.v1` — distinct from the signing domain so a body
32
+ * hash can never be presented as a request signature preimage.
33
+ */
34
+ const REQUEST_BODY_DOMAIN = utf8('calimero.auth.request.body.v1');
35
+ /** The header a signed request carries. */
36
+ export const REQUEST_PROOF_HEADER = 'X-Calimero-Proof';
37
+ /** Seconds a signature stays honourable. Short, because it bounds replay. */
38
+ const DEFAULT_VALID_FOR = 300;
39
+ /**
40
+ * Sign one request and return the `X-Calimero-Proof` value, hex.
41
+ *
42
+ * The encoding is borsh, and it is assembled rather than re-derived: the
43
+ * account proof is the **first** field of `CallerProof`, so the credential hex
44
+ * a caller already holds is exactly its own prefix. Concatenating avoids a
45
+ * second implementation of a structure the node deserializes — the class of bug
46
+ * that surfaces far away, as a node refusing a credential nothing can explain.
47
+ *
48
+ * CallerProof = <account_proof bytes> || 0x00 (no session) || RequestSig
49
+ * RequestSig = u32le(len method) || method
50
+ * || u32le(len path) || path
51
+ * || body_hash[32]
52
+ * || u64le(issued_at) || u64le(expires_at)
53
+ * || signature[64]
54
+ */
55
+ export async function signRequestProof(input) {
56
+ const { method, path, credential } = input;
57
+ if (!method)
58
+ throw new Error('signRequestProof needs the method the request will use');
59
+ if (!path.startsWith('/')) {
60
+ throw new Error(`signRequestProof needs a path, got ${JSON.stringify(path)}`);
61
+ }
62
+ if (path.includes('?')) {
63
+ throw new Error(`the path must carry no query string, got ${JSON.stringify(path)} — ` +
64
+ 'a query is excluded from the signature on purpose, because a proxy may ' +
65
+ 'rewrite one. Anything that must be bound belongs in the body.');
66
+ }
67
+ const bodyBytes = input.body === undefined
68
+ ? new Uint8Array()
69
+ : typeof input.body === 'string'
70
+ ? utf8(input.body)
71
+ : input.body;
72
+ const bodyHash = await domainHash(REQUEST_BODY_DOMAIN, [bodyBytes]);
73
+ const issuedAt = Math.floor(input.now ?? Date.now() / 1000);
74
+ const expiresAt = issuedAt + (input.validForSeconds ?? DEFAULT_VALID_FOR);
75
+ const payload = await domainHash(REQUEST_SIGN_DOMAIN, [
76
+ utf8(method),
77
+ utf8(path),
78
+ bodyHash,
79
+ u64le(issuedAt),
80
+ u64le(expiresAt),
81
+ ]);
82
+ const signature = await signPayload(payload, input);
83
+ if (signature.length !== 64) {
84
+ throw new Error(`the signer returned ${signature.length} bytes, not a 64-byte signature`);
85
+ }
86
+ const requestSig = concat(u32le(utf8(method).length), utf8(method), u32le(utf8(path).length), utf8(path), bodyHash, u64le(issuedAt), u64le(expiresAt), signature);
87
+ // The credential IS the account-proof prefix. Length is not asserted: it varies
88
+ // with the handoff chain, and a wrong one fails as a refused proof rather than
89
+ // silently as something else.
90
+ const accountProof = hexToBytes(credential, 'credential');
91
+ // 0x00: borsh's `None`. The device key signed the request, so there is no
92
+ // session link — a different encoding, not a shorter one.
93
+ return hex(concat(accountProof, new Uint8Array([0]), requestSig));
94
+ }
95
+ async function signPayload(payload, input) {
96
+ if (input.signer)
97
+ return input.signer.sign(payload);
98
+ if (!input.deviceSecret) {
99
+ throw new Error('signRequestProof needs either `deviceSecret` or `signer`');
100
+ }
101
+ const key = await importSigningKey(input.deviceSecret);
102
+ return new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, payload));
103
+ }
104
+ /** Hex of unknown length — `fromHex` requires a fixed byte count. */
105
+ function hexToBytes(value, label) {
106
+ const clean = value.trim();
107
+ if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length % 2 !== 0) {
108
+ throw new Error(`${label} is not an even-length hex string`);
109
+ }
110
+ return fromHex(clean, label, clean.length / 2);
111
+ }
@@ -27,10 +27,31 @@ export declare class RpcError extends Error {
27
27
  export declare function jsonRpcCall<T>(httpClient: HttpClient, method: string, params: unknown): Promise<T>;
28
28
  export declare class RpcClient {
29
29
  private httpClient;
30
+ /**
31
+ * Which write path this is. Present so `RpcClient` satisfies
32
+ * `ExecuteTransport` as-is — the transport abstraction adopted *this* class's
33
+ * `execute` shape as the canonical one, so the node side is the interface
34
+ * rather than an adapter onto it.
35
+ */
36
+ readonly kind: "node";
37
+ /** A node serves `/sse` and `/ws` to the credential it issued. */
38
+ readonly canSubscribe = true;
30
39
  constructor(opts: {
31
40
  httpClient: HttpClient;
32
41
  });
33
42
  execute<T = unknown>(params: ExecuteParams): Promise<T>;
43
+ /**
44
+ * The same call, in the shape the transport abstraction shares with the
45
+ * relay.
46
+ *
47
+ * `rootHash` is left absent rather than synthesized: JSON-RPC `execute` does
48
+ * not report the context's scope root, and inventing a value — or a `''` —
49
+ * would make "the node does not tell us" indistinguishable from an answer.
50
+ */
51
+ executeWithMetadata<T = unknown>(params: ExecuteParams): Promise<{
52
+ returns: T;
53
+ transport: 'node';
54
+ }>;
34
55
  /**
35
56
  * One-tap owner-driven convert: re-signs the caller's identity-gated entries
36
57
  * to the current schema. The export converts all of the caller's
package/dist/rpc/index.js CHANGED
@@ -29,6 +29,15 @@ export async function jsonRpcCall(httpClient, method, params) {
29
29
  }
30
30
  export class RpcClient {
31
31
  constructor(opts) {
32
+ /**
33
+ * Which write path this is. Present so `RpcClient` satisfies
34
+ * `ExecuteTransport` as-is — the transport abstraction adopted *this* class's
35
+ * `execute` shape as the canonical one, so the node side is the interface
36
+ * rather than an adapter onto it.
37
+ */
38
+ this.kind = 'node';
39
+ /** A node serves `/sse` and `/ws` to the credential it issued. */
40
+ this.canSubscribe = true;
32
41
  this.httpClient = opts.httpClient;
33
42
  }
34
43
  async execute(params) {
@@ -42,6 +51,17 @@ export class RpcClient {
42
51
  }
43
52
  return result;
44
53
  }
54
+ /**
55
+ * The same call, in the shape the transport abstraction shares with the
56
+ * relay.
57
+ *
58
+ * `rootHash` is left absent rather than synthesized: JSON-RPC `execute` does
59
+ * not report the context's scope root, and inventing a value — or a `''` —
60
+ * would make "the node does not tell us" indistinguishable from an answer.
61
+ */
62
+ async executeWithMetadata(params) {
63
+ return { returns: await this.execute(params), transport: 'node' };
64
+ }
45
65
  /**
46
66
  * One-tap owner-driven convert: re-signs the caller's identity-gated entries
47
67
  * to the current schema. The export converts all of the caller's
@@ -0,0 +1,6 @@
1
+ export { MeroClient, createMeroClient } from './mero-client.js';
2
+ export type { MeroClientConfig, NodeTransportConfig, RelayTransportConfig, } from './mero-client.js';
3
+ export { RelayTransport } from './relay-transport.js';
4
+ export { RelayObserver, defaultAudience } from './relay-observer.js';
5
+ export type { RelayObserveConfig } from './relay-observer.js';
6
+ export type { ExecuteTransport, ExecuteResult, TransportKind } from './types.js';
@@ -0,0 +1,3 @@
1
+ export { MeroClient, createMeroClient } from './mero-client.js';
2
+ export { RelayTransport } from './relay-transport.js';
3
+ export { RelayObserver, defaultAudience } from './relay-observer.js';
@@ -0,0 +1,286 @@
1
+ /**
2
+ * One client, two transports — chosen once, at construction.
3
+ *
4
+ * # The problem this exists for
5
+ *
6
+ * An app that wants to move from "the user has a node" to "the user has a key
7
+ * and a relay" had to rewrite itself, because the two write paths are different
8
+ * objects with different method shapes. That makes the transport a property of
9
+ * the *application source*, when it is really a property of the connection: the
10
+ * same screen, the same hook, the same `execute` call wants to work either way.
11
+ *
12
+ * `createMeroClient` takes the decision at construction and nowhere else. After
13
+ * that, `client.rpc.execute({ contextId, method, argsJson })` is the same call
14
+ * against a node and against a relay — same arguments, same return type. The
15
+ * node shape is the one both speak, because shipped apps already write it and
16
+ * the alternative would be a breaking change wearing an abstraction's clothes.
17
+ *
18
+ * # Defaulting to the node is a compatibility guarantee
19
+ *
20
+ * `createMeroClient({ baseUrl })` is `createMeroJs({ baseUrl })` with a facade
21
+ * in front. No `transport` field means `'node'`, so nothing an existing app
22
+ * does can accidentally select the relay.
23
+ *
24
+ * # Events work on both transports, given one input
25
+ *
26
+ * A relay is an ordinary node, so it serves `/sse` and `/ws` to a credential it
27
+ * issued, and a keyholder can obtain one by logging in with its account proof.
28
+ * `client.events` and `client.ws` are therefore the same call on both
29
+ * transports — provided the caller supplies the relay node's device signing
30
+ * key, which must be learned out of band and is the one thing that cannot be
31
+ * inferred. See `./relay-observer.ts` for why, and mdma #312 for the hosted
32
+ * case. Without it, {@link MeroClient.canSubscribe} is `false` and the
33
+ * accessors throw naming exactly that.
34
+ *
35
+ * # What is deliberately NOT unified
36
+ *
37
+ * Reads and admin. A relay serves exactly two routes to a caller with no
38
+ * credential — `GET` and `POST /admin-api/contexts/:id/intents` — and while a
39
+ * session obtained by logging in carries `context:query`, `sdk.admin` wants
40
+ * `admin`, which the `account_proof` provider does not grant and a keyholder
41
+ * should never ask for.
42
+ *
43
+ * So those accessors throw on a relay client, with a message naming what is
44
+ * missing, rather than returning something that quietly connects somewhere
45
+ * else. Falling back to "some node" would be the worst of the options: the app
46
+ * would observe a node it never chose, under a credential it never granted, and
47
+ * find out only when the two disagreed. `canSubscribe` and `transport` are
48
+ * there so an app can branch *before* touching any of it.
49
+ */
50
+ import { MeroJs } from '../mero-js.js';
51
+ import type { MeroJsConfig } from '../mero-js.js';
52
+ import { RelayClient } from '../relay/relay-client.js';
53
+ import type { RelayClientConfig } from '../relay/relay-client.js';
54
+ import type { RelayObserveConfig } from './relay-observer.js';
55
+ import type { ExecuteTransport, TransportKind } from './types.js';
56
+ import type { AdminApiClient } from '../admin-api/index.js';
57
+ import type { ProofCredential } from '../request-proof/index.js';
58
+ import { SseClient } from '../events/sse.js';
59
+ import type { AuthApiClient } from '../auth-api/index.js';
60
+ import type { WsClient } from '../events/ws.js';
61
+ import type { EphemeralClient } from '../ephemeral/index.js';
62
+ import type { CloudClient } from '../cloud/cloud-client.js';
63
+ /** The node transport: everything `createMeroJs` takes, plus an explicit opt-in. */
64
+ export interface NodeTransportConfig extends MeroJsConfig {
65
+ /**
66
+ * Optional, and the default. Spelled out so a config object can be built
67
+ * generically without the node case looking like an omission.
68
+ */
69
+ transport?: 'node';
70
+ }
71
+ /** The relay transport: delegated execution, no node and no node credential. */
72
+ export interface RelayTransportConfig {
73
+ transport: 'relay';
74
+ /**
75
+ * The relay to write through — a configured {@link RelayClient}, or the
76
+ * config to build one from.
77
+ *
78
+ * An already-built client is accepted because the useful ones are not built
79
+ * by hand: `connectCloud` discovers the relay URL and executor account from a
80
+ * cloud sign-in and hands back a ready client. Requiring the config here
81
+ * would have that caller take the client apart to put it back together.
82
+ */
83
+ relay: RelayClient | RelayClientConfig;
84
+ /**
85
+ * A session on the relay, if this client has one.
86
+ *
87
+ * The delegated path has **two** credentials, not one. A warrant authorises a
88
+ * single intent and is what `execute` sends; a session — minted by
89
+ * `account_proof` from the same device key, see `login()` — is what the
90
+ * caller-scoped admin READS are authenticated by (`context:list-own`,
91
+ * `namespace:list-own`). They are independent: a client with only warrants
92
+ * writes but cannot list, which is what `admin` throwing used to describe.
93
+ *
94
+ * Supply a token, or a function returning the current one so a refreshed
95
+ * session is picked up without rebuilding the client.
96
+ */
97
+ session?: string | (() => Promise<string | undefined>);
98
+ /**
99
+ * How this client observes the relay node, if it can.
100
+ *
101
+ * Omit it — or pass it with no `nodeKey` — and the client writes but does not
102
+ * observe: `canSubscribe` is `false` and `events`/`ws` throw naming the
103
+ * missing key. Supply `{ nodeKey }` and `client.events` is the very same
104
+ * `SseClient` a node client hands out.
105
+ *
106
+ * The shape takes a nullable key on purpose, so the hosted path is one code
107
+ * path rather than two: `connectCloud` always passes this object, with
108
+ * whatever key the cloud reported, and starts working the day mdma #312
109
+ * publishes one. Nothing here ever fills the key in.
110
+ */
111
+ observe?: RelayObserveConfig;
112
+ /**
113
+ * The device cert this client authenticates its own requests with.
114
+ *
115
+ * This is the shortest path to everything but writes, and it needs no session
116
+ * and no node key. The device key signs each request — `X-Calimero-Proof` —
117
+ * and the node verifies the signature against the certificate the account
118
+ * signed, with no store read and no prior relationship. Measured against a
119
+ * node built from core `aecba573b`: the caller-scoped reads answer `200`,
120
+ * `/sse` answers `200`, and a `/ws` upgrade completes `101`.
121
+ *
122
+ * Supplying this makes {@link MeroClient.admin} and {@link MeroClient.events}
123
+ * available and {@link MeroClient.canSubscribe} true, with no `session` and no
124
+ * `observe.nodeKey`. Those two remain for the case they were built for: a
125
+ * device key behind a boundary worth crossing once, where a per-session
126
+ * signature beats a per-request one.
127
+ */
128
+ proof?: ProofCredential;
129
+ }
130
+ export type MeroClientConfig = NodeTransportConfig | RelayTransportConfig;
131
+ /**
132
+ * A connection whose transport was decided at construction.
133
+ *
134
+ * Everything reachable on a node client is reachable in exactly the shape
135
+ * `MeroJs` exposes it, by delegation rather than re-implementation — this adds
136
+ * no behaviour to the node path and cannot drift from it.
137
+ */
138
+ export declare class MeroClient {
139
+ /** Which path writes take. Set once, never changes. */
140
+ readonly transport: TransportKind;
141
+ private readonly nodeClient;
142
+ private readonly rpcTransport;
143
+ /** The relay's event session, or `null` on a node client / with no node key. */
144
+ private readonly relayObserver;
145
+ /** Admin reads against the relay, when a session or a proof was supplied. */
146
+ private readonly relayAdmin;
147
+ /**
148
+ * Events off the relay authenticated by a request proof, built lazily.
149
+ *
150
+ * Separate from {@link relayObserver} rather than a mode inside it: the
151
+ * observer's whole subject is a session addressed to a node key, and a proof
152
+ * has neither. Holding the authorizer instead of the client keeps construction
153
+ * free of the first signature.
154
+ */
155
+ private readonly proofAuthorizer;
156
+ private readonly relayUrl;
157
+ private proofEvents;
158
+ constructor(config: MeroClientConfig);
159
+ /**
160
+ * The write surface. Identical on both transports — this is the whole point.
161
+ *
162
+ * On a node this IS the `RpcClient` a `MeroJs` would have handed out, not a
163
+ * wrapper around it, so `client.rpc` and `client.node.rpc` are the same
164
+ * object.
165
+ */
166
+ get rpc(): ExecuteTransport;
167
+ /**
168
+ * Whether `events` / `ws` will work on this client.
169
+ *
170
+ * Always `true` on the node transport. On the relay transport it says whether
171
+ * a node key was supplied — the relay can be observed, but only by a caller
172
+ * who can name the key the login statement is signed against.
173
+ */
174
+ get canSubscribe(): boolean;
175
+ /** The underlying `MeroJs`. Node transport only. */
176
+ get node(): MeroJs;
177
+ /** The underlying `RelayClient`, for `describe()`. Relay transport only. */
178
+ get relay(): RelayClient;
179
+ /**
180
+ * Admin API. Node transport only.
181
+ *
182
+ * Two different things live behind this accessor today, and only one of them
183
+ * is genuinely unavailable through a relay.
184
+ *
185
+ * The **mutations** — `createNamespace`, `setMemberCapabilities`,
186
+ * `upgradeGroup` and the rest — are operator actions on somebody else's
187
+ * machine. A keyholder does not get those, and should not; admin is for
188
+ * admins.
189
+ *
190
+ * The **reads** are a different matter. `getContext`, `getContexts`,
191
+ * `getApplication`, `listNamespaces`, `getBlob` are an app asking what it is
192
+ * looking at, not an operator administering anything. They sit under
193
+ * `/admin-api/` for historical reasons, and a delegated session is refused
194
+ * them (measured: `403`, while the same session reads context state through
195
+ * `POST /contexts/{ctx}/query` and writes through `/intents` quite happily).
196
+ *
197
+ * So this is the one place where "the same code runs on both transports"
198
+ * does not hold yet, and it is a missing surface rather than a missing
199
+ * permission. A transport-independent read API is designed but not built; the
200
+ * throw below is deliberate until it exists, because the alternative — quietly
201
+ * reaching for a node the caller did not choose — is worse than a clear stop.
202
+ */
203
+ /**
204
+ * The admin surface.
205
+ *
206
+ * On a node client this is the node's own. On a relay client it exists only
207
+ * when a `session` was supplied, and it is **the relay's admin API
208
+ * authenticated as the account** — so the caller-scoped reads answer:
209
+ * `getContexts` and `listNamespaces` return this account's own, because
210
+ * `account_proof` grants `context:list-own` and `namespace:list-own`.
211
+ *
212
+ * Mutations are not filtered out here, deliberately. A delegated session does
213
+ * not carry the grants they require, so the node refuses them with a `403`
214
+ * that names the missing permission — which is a better answer than a
215
+ * client-side guess, and keeps the policy in the one place that owns it.
216
+ */
217
+ get admin(): AdminApiClient;
218
+ /** Auth API. Node transport only — a relay issues no node credential. */
219
+ get auth(): AuthApiClient;
220
+ /**
221
+ * SSE events — on **both** transports.
222
+ *
223
+ * On a relay client this is an `SseClient` against the relay node, fed by a
224
+ * session minted with the author's account proof. The session is minted
225
+ * lazily, on the first token the stream asks for, so reading this getter
226
+ * performs no network call.
227
+ *
228
+ * Throws only when the relay client was given no node key: the one input
229
+ * that cannot be inferred. See {@link missingNodeKey}.
230
+ */
231
+ get events(): SseClient;
232
+ /** WebSocket events, on both transports. @experimental — prefer `events`. */
233
+ get ws(): WsClient;
234
+ /**
235
+ * Ephemeral presence. Node transport only.
236
+ *
237
+ * Not extended to the relay even though a logged-in session could reach both
238
+ * halves of it mechanically. Publishing resolves the author from an *owned
239
+ * context identity on the node*, and whether a delegated session has one has
240
+ * not been established — a presence client that silently published nothing,
241
+ * or published as the wrong identity, is worse than one that says it is not
242
+ * available. The read half is not lost: presence arrives on `client.events`
243
+ * like any other context event, and can be filtered there.
244
+ *
245
+ * The rejected alternative was to expose it over the relay session and let
246
+ * `set` fail at the node. That trades a clear construction-time answer for a
247
+ * runtime failure inside a UI that has already rendered.
248
+ */
249
+ get ephemeral(): EphemeralClient;
250
+ /**
251
+ * Calimero Cloud.
252
+ *
253
+ * Available on both transports, because it is neither: the cloud session is
254
+ * independent of the node credential and of the relay's signing key, and a
255
+ * relay-transport app still needs it to list namespaces and manage billing.
256
+ */
257
+ get cloud(): CloudClient;
258
+ /**
259
+ * Release whatever the transport is holding.
260
+ *
261
+ * Safe on a relay client, where there is nothing open to close — a caller
262
+ * that tears down on unmount should not have to know which transport it got.
263
+ */
264
+ close(): void;
265
+ }
266
+ /**
267
+ * Build a client for the chosen transport. Node unless told otherwise.
268
+ *
269
+ * ```ts
270
+ * // unchanged, and still the default
271
+ * const client = createMeroClient({ baseUrl: 'http://localhost:2428' });
272
+ *
273
+ * // the same call sites, through a relay
274
+ * const client = createMeroClient({
275
+ * transport: 'relay',
276
+ * relay: connection.relay,
277
+ * // the relay node's signing key, learned out of band — without it the
278
+ * // client writes but does not observe
279
+ * observe: { nodeKey },
280
+ * });
281
+ *
282
+ * await client.rpc.execute({ contextId, method: 'set', argsJson: { key, value } });
283
+ * if (client.canSubscribe) client.events.on('event', onEvent);
284
+ * ```
285
+ */
286
+ export declare function createMeroClient(config: MeroClientConfig): MeroClient;