@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/README.md +47 -0
- package/dist/admin-api/admin-client.d.ts +22 -1
- package/dist/admin-api/admin-client.js +27 -1
- package/dist/admin-api/admin-types.d.ts +42 -2
- package/dist/capabilities.d.ts +14 -2
- package/dist/capabilities.js +15 -3
- package/dist/cloud/cloud-client.d.ts +264 -0
- package/dist/cloud/cloud-client.js +319 -1
- package/dist/cloud/connect.d.ts +94 -0
- package/dist/cloud/connect.js +150 -0
- package/dist/cloud/index.d.ts +3 -1
- package/dist/cloud/index.js +1 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +631 -29
- package/dist/index.cjs.map +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/index.mjs +631 -29
- package/dist/index.mjs.map +3 -3
- package/dist/mero-js.d.ts +28 -0
- package/dist/mero-js.js +17 -0
- package/dist/relay/index.d.ts +4 -0
- package/dist/relay/index.js +2 -0
- package/dist/relay/nonce-source.d.ts +60 -0
- package/dist/relay/nonce-source.js +95 -0
- package/dist/relay/relay-client.d.ts +141 -0
- package/dist/relay/relay-client.js +157 -0
- package/package.json +2 -1
package/dist/mero-js.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { RpcClient } from './rpc/index.js';
|
|
|
6
6
|
import { SseClient } from './events/sse.js';
|
|
7
7
|
import { WsClient } from './events/ws.js';
|
|
8
8
|
import { EphemeralClient } from './ephemeral/index.js';
|
|
9
|
+
import { CloudClient } from './cloud/cloud-client.js';
|
|
10
|
+
import type { CloudClientConfig } from './cloud/cloud-client.js';
|
|
9
11
|
export interface MeroJsConfig {
|
|
10
12
|
/** Base URL for the Calimero node */
|
|
11
13
|
baseUrl: string;
|
|
@@ -28,6 +30,20 @@ export interface MeroJsConfig {
|
|
|
28
30
|
* never let a UI failure mask the auth error.
|
|
29
31
|
*/
|
|
30
32
|
onAuthRevoked?: () => Promise<void> | void;
|
|
33
|
+
/**
|
|
34
|
+
* Calimero Cloud, reachable as `sdk.cloud`.
|
|
35
|
+
*
|
|
36
|
+
* A node connection and a cloud account are separate things a client can have
|
|
37
|
+
* either of, both of, or neither — a desktop app with a local node and a
|
|
38
|
+
* cloud subscription has both, and needs them from one object. So this is
|
|
39
|
+
* configuration on the existing SDK rather than a second SDK: pass the
|
|
40
|
+
* session token (or none, and sign in through `sdk.cloud`) and the node half
|
|
41
|
+
* is unaffected.
|
|
42
|
+
*
|
|
43
|
+
* For a caller with *no* node at all, `connectCloud` is the entry point
|
|
44
|
+
* instead — it returns a connection that can write without one.
|
|
45
|
+
*/
|
|
46
|
+
cloud?: CloudClientConfig;
|
|
31
47
|
}
|
|
32
48
|
export interface TokenData {
|
|
33
49
|
access_token: string;
|
|
@@ -50,6 +66,7 @@ export declare class MeroJs {
|
|
|
50
66
|
private wsClient;
|
|
51
67
|
private wsWarned;
|
|
52
68
|
private ephemeralClient?;
|
|
69
|
+
private cloudClient?;
|
|
53
70
|
constructor(config: MeroJsConfig);
|
|
54
71
|
/**
|
|
55
72
|
* Get the Auth API client
|
|
@@ -74,6 +91,17 @@ export declare class MeroJs {
|
|
|
74
91
|
* stream rather than opening a second connection.
|
|
75
92
|
*/
|
|
76
93
|
get ephemeral(): EphemeralClient;
|
|
94
|
+
/**
|
|
95
|
+
* Calimero Cloud (lazy initialized).
|
|
96
|
+
*
|
|
97
|
+
* Available whether or not `cloud` was configured — an app that signs in
|
|
98
|
+
* later gets an unauthenticated client it can call `signInWithGoogle` on,
|
|
99
|
+
* which is one fewer construction path than making the getter conditional.
|
|
100
|
+
* It is entirely independent of this instance's node credential: the cloud
|
|
101
|
+
* knows nothing about the node's tokens and the node knows nothing about the
|
|
102
|
+
* cloud session.
|
|
103
|
+
*/
|
|
104
|
+
get cloud(): CloudClient;
|
|
77
105
|
/**
|
|
78
106
|
* Get the WebSocket event client (lazy initialized).
|
|
79
107
|
* @experimental Use `events` (SSE) for production. WsClient is experimental.
|
package/dist/mero-js.js
CHANGED
|
@@ -6,6 +6,7 @@ import { RpcClient } from './rpc/index.js';
|
|
|
6
6
|
import { SseClient } from './events/sse.js';
|
|
7
7
|
import { WsClient } from './events/ws.js';
|
|
8
8
|
import { EphemeralClient } from './ephemeral/index.js';
|
|
9
|
+
import { CloudClient } from './cloud/cloud-client.js';
|
|
9
10
|
/** Name of the Web Lock that serializes token refresh across tabs / webviews. */
|
|
10
11
|
const REFRESH_LOCK_NAME = 'mero-js:token-refresh';
|
|
11
12
|
/** Returns `navigator.locks` when the runtime supports the Web Locks API. */
|
|
@@ -157,6 +158,22 @@ export class MeroJs {
|
|
|
157
158
|
}
|
|
158
159
|
return this.ephemeralClient;
|
|
159
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Calimero Cloud (lazy initialized).
|
|
163
|
+
*
|
|
164
|
+
* Available whether or not `cloud` was configured — an app that signs in
|
|
165
|
+
* later gets an unauthenticated client it can call `signInWithGoogle` on,
|
|
166
|
+
* which is one fewer construction path than making the getter conditional.
|
|
167
|
+
* It is entirely independent of this instance's node credential: the cloud
|
|
168
|
+
* knows nothing about the node's tokens and the node knows nothing about the
|
|
169
|
+
* cloud session.
|
|
170
|
+
*/
|
|
171
|
+
get cloud() {
|
|
172
|
+
if (!this.cloudClient) {
|
|
173
|
+
this.cloudClient = new CloudClient(this.config.cloud ?? {});
|
|
174
|
+
}
|
|
175
|
+
return this.cloudClient;
|
|
176
|
+
}
|
|
160
177
|
/**
|
|
161
178
|
* Get the WebSocket event client (lazy initialized).
|
|
162
179
|
* @experimental Use `events` (SSE) for production. WsClient is experimental.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { RelayClient, IntentRefusedError } from './relay-client.js';
|
|
2
|
+
export type { RelayClientConfig, RelayDescription, IntentResult } from './relay-client.js';
|
|
3
|
+
export { createMemoryNonceSource, createLocalStorageNonceSource } from './nonce-source.js';
|
|
4
|
+
export type { NonceSource } from './nonce-source.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a warrant's nonce comes from.
|
|
3
|
+
*
|
|
4
|
+
* # Why this is a named concept and not a counter in the relay client
|
|
5
|
+
*
|
|
6
|
+
* A warrant's nonce is spent, once, per `(context, author device)` on every
|
|
7
|
+
* node that applies the delta. So the sequence a client emits is not a local
|
|
8
|
+
* detail — it is state the network remembers, and the client is the only party
|
|
9
|
+
* that can keep it.
|
|
10
|
+
*
|
|
11
|
+
* The receiving ledger is a **sliding window** 64 wide, not a high-water mark,
|
|
12
|
+
* which is what makes the two failure modes so different:
|
|
13
|
+
*
|
|
14
|
+
* - **Skipping** numbers is free. Gossip gives no ordering, so the window
|
|
15
|
+
* exists precisely to accept whatever unseen nonce arrives.
|
|
16
|
+
* - **Restarting** the sequence is fatal. A tab that reloads and resumes at `1`
|
|
17
|
+
* re-presents nonces the network has already spent, and every one of those
|
|
18
|
+
* writes is refused as a replay — indistinguishable, to the user, from the
|
|
19
|
+
* app silently not saving.
|
|
20
|
+
*
|
|
21
|
+
* That asymmetry is why persistence is the default here and an in-memory source
|
|
22
|
+
* is the one you have to ask for. It is also why a source may jump: reserving a
|
|
23
|
+
* block and losing the tail is the correct trade.
|
|
24
|
+
*/
|
|
25
|
+
/** Produces the next nonce for one author device. Must never go backwards. */
|
|
26
|
+
export interface NonceSource {
|
|
27
|
+
next(): Promise<bigint>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A counter in memory, starting at `start`.
|
|
31
|
+
*
|
|
32
|
+
* Correct only for a process whose lifetime is the whole sequence — a script, a
|
|
33
|
+
* server-side worker that owns its own device key, a test. In a browser tab it
|
|
34
|
+
* resets on reload and replays; use {@link createLocalStorageNonceSource}
|
|
35
|
+
* there.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createMemoryNonceSource(start?: number | bigint): NonceSource;
|
|
38
|
+
/**
|
|
39
|
+
* A counter persisted in `localStorage`, so a reload continues the sequence.
|
|
40
|
+
*
|
|
41
|
+
* Key it per author **device**, not per account: two devices of one account are
|
|
42
|
+
* independent replicas with independent sequences, and sharing a counter would
|
|
43
|
+
* have them refusing each other's warrants.
|
|
44
|
+
*
|
|
45
|
+
* **Reserve-then-hand-out.** The stored value is advanced *before* the nonce is
|
|
46
|
+
* returned, so a crash between the two loses a number rather than reusing one.
|
|
47
|
+
* Losing a number costs nothing (the window accepts gaps); reusing one costs a
|
|
48
|
+
* write.
|
|
49
|
+
*
|
|
50
|
+
* Concurrent tabs are the case this cannot fully solve, because `localStorage`
|
|
51
|
+
* has no atomic read-modify-write. Two tabs racing can hand out the same nonce,
|
|
52
|
+
* and the loser's write is refused as a replay rather than silently
|
|
53
|
+
* misattributed — a visible error, not corruption. An app that genuinely writes
|
|
54
|
+
* from several tabs at once should hold a Web Lock around `next()` or give each
|
|
55
|
+
* tab its own device.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createLocalStorageNonceSource(key: string, storage?: {
|
|
58
|
+
getItem(k: string): string | null;
|
|
59
|
+
setItem(k: string, v: string): void;
|
|
60
|
+
}): NonceSource;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a warrant's nonce comes from.
|
|
3
|
+
*
|
|
4
|
+
* # Why this is a named concept and not a counter in the relay client
|
|
5
|
+
*
|
|
6
|
+
* A warrant's nonce is spent, once, per `(context, author device)` on every
|
|
7
|
+
* node that applies the delta. So the sequence a client emits is not a local
|
|
8
|
+
* detail — it is state the network remembers, and the client is the only party
|
|
9
|
+
* that can keep it.
|
|
10
|
+
*
|
|
11
|
+
* The receiving ledger is a **sliding window** 64 wide, not a high-water mark,
|
|
12
|
+
* which is what makes the two failure modes so different:
|
|
13
|
+
*
|
|
14
|
+
* - **Skipping** numbers is free. Gossip gives no ordering, so the window
|
|
15
|
+
* exists precisely to accept whatever unseen nonce arrives.
|
|
16
|
+
* - **Restarting** the sequence is fatal. A tab that reloads and resumes at `1`
|
|
17
|
+
* re-presents nonces the network has already spent, and every one of those
|
|
18
|
+
* writes is refused as a replay — indistinguishable, to the user, from the
|
|
19
|
+
* app silently not saving.
|
|
20
|
+
*
|
|
21
|
+
* That asymmetry is why persistence is the default here and an in-memory source
|
|
22
|
+
* is the one you have to ask for. It is also why a source may jump: reserving a
|
|
23
|
+
* block and losing the tail is the correct trade.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* A counter in memory, starting at `start`.
|
|
27
|
+
*
|
|
28
|
+
* Correct only for a process whose lifetime is the whole sequence — a script, a
|
|
29
|
+
* server-side worker that owns its own device key, a test. In a browser tab it
|
|
30
|
+
* resets on reload and replays; use {@link createLocalStorageNonceSource}
|
|
31
|
+
* there.
|
|
32
|
+
*/
|
|
33
|
+
export function createMemoryNonceSource(start = 1) {
|
|
34
|
+
let next = BigInt(start);
|
|
35
|
+
if (next < 1n) {
|
|
36
|
+
throw new Error('nonce sequence starts at 1');
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
next() {
|
|
40
|
+
const value = next;
|
|
41
|
+
next += 1n;
|
|
42
|
+
return Promise.resolve(value);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A counter persisted in `localStorage`, so a reload continues the sequence.
|
|
48
|
+
*
|
|
49
|
+
* Key it per author **device**, not per account: two devices of one account are
|
|
50
|
+
* independent replicas with independent sequences, and sharing a counter would
|
|
51
|
+
* have them refusing each other's warrants.
|
|
52
|
+
*
|
|
53
|
+
* **Reserve-then-hand-out.** The stored value is advanced *before* the nonce is
|
|
54
|
+
* returned, so a crash between the two loses a number rather than reusing one.
|
|
55
|
+
* Losing a number costs nothing (the window accepts gaps); reusing one costs a
|
|
56
|
+
* write.
|
|
57
|
+
*
|
|
58
|
+
* Concurrent tabs are the case this cannot fully solve, because `localStorage`
|
|
59
|
+
* has no atomic read-modify-write. Two tabs racing can hand out the same nonce,
|
|
60
|
+
* and the loser's write is refused as a replay rather than silently
|
|
61
|
+
* misattributed — a visible error, not corruption. An app that genuinely writes
|
|
62
|
+
* from several tabs at once should hold a Web Lock around `next()` or give each
|
|
63
|
+
* tab its own device.
|
|
64
|
+
*/
|
|
65
|
+
export function createLocalStorageNonceSource(key, storage) {
|
|
66
|
+
const store = storage ?? globalThis.localStorage;
|
|
67
|
+
if (!store) {
|
|
68
|
+
throw new Error('no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence');
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
next() {
|
|
72
|
+
const raw = store.getItem(key);
|
|
73
|
+
// A missing, empty, or corrupted value means "no sequence recorded", and
|
|
74
|
+
// the safe reading of that is not 1 — a cleared storage on a device that
|
|
75
|
+
// has already written would replay. But the client cannot know, and 1 is
|
|
76
|
+
// the only defensible start, so it is what a fresh key gets. This is why
|
|
77
|
+
// the doc above tells apps to key per device: a device whose storage was
|
|
78
|
+
// cleared should be re-paired rather than resumed.
|
|
79
|
+
let current = 0n;
|
|
80
|
+
if (raw !== null && raw !== '') {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = BigInt(raw);
|
|
83
|
+
if (parsed > 0n)
|
|
84
|
+
current = parsed;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
current = 0n;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const value = current + 1n;
|
|
91
|
+
store.setItem(key, value.toString());
|
|
92
|
+
return Promise.resolve(value);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write to a context through a relay, holding nothing but a signing key.
|
|
3
|
+
*
|
|
4
|
+
* # What this replaces
|
|
5
|
+
*
|
|
6
|
+
* `sdk.rpc.execute` asks a node to run a method *as itself*: it needs a node,
|
|
7
|
+
* that node needs to be a member, and the caller needs a credential on it. For
|
|
8
|
+
* a browser tab, a phone, or an agent, none of those three is available — the
|
|
9
|
+
* runtime cannot compile WASM against materialized state, never received the
|
|
10
|
+
* scope key that seals the deltas, and has no account on anybody's node.
|
|
11
|
+
*
|
|
12
|
+
* This client is the other path. The caller signs a warrant locally, the relay
|
|
13
|
+
* runs the method as the *author's* principal, and the resulting delta is
|
|
14
|
+
* attributed to the author's account and device — their replica slot, their
|
|
15
|
+
* membership, their name in the app. The relay's key signs the envelope and
|
|
16
|
+
* nothing else.
|
|
17
|
+
*
|
|
18
|
+
* # The three preconditions, and why they are checked in this order
|
|
19
|
+
*
|
|
20
|
+
* `describe()` answers the first two in one call, before anything is signed:
|
|
21
|
+
* the relay's executor account (which the warrant must name) and whether it
|
|
22
|
+
* holds `CAN_AUTHOR_ON_BEHALF` on the owning group. The third — that the
|
|
23
|
+
* author's account is a member — only the group knows, and it surfaces as a
|
|
24
|
+
* refusal.
|
|
25
|
+
*
|
|
26
|
+
* Checking before signing is not politeness. A warrant consumes a nonce from a
|
|
27
|
+
* monotonic per-device sequence, and one minted against the wrong executor is
|
|
28
|
+
* unspendable: the number is gone and the write never happened.
|
|
29
|
+
*/
|
|
30
|
+
import type { NonceSource } from './nonce-source.js';
|
|
31
|
+
/** Where and as whom to write, plus the key that consents. */
|
|
32
|
+
export interface RelayClientConfig {
|
|
33
|
+
/**
|
|
34
|
+
* The relay's base URL — the node's origin, not a path.
|
|
35
|
+
*
|
|
36
|
+
* Discover it from {@link CloudClient.getNamespaceRelays} for a hosted
|
|
37
|
+
* namespace, or take it from the operator for a self-hosted one.
|
|
38
|
+
*/
|
|
39
|
+
relayUrl: string;
|
|
40
|
+
/**
|
|
41
|
+
* The account the relay writes as, hex — a warrant's `executor`.
|
|
42
|
+
*
|
|
43
|
+
* An account rather than a key, so one of the relay's processes rotating its
|
|
44
|
+
* signing key does not void warrants already issued to it. Left unset, the
|
|
45
|
+
* first `execute` learns it from {@link RelayClient.describe}.
|
|
46
|
+
*/
|
|
47
|
+
executorAccount?: string;
|
|
48
|
+
/** The author's account, hex — whose consent the warrant carries. */
|
|
49
|
+
authorAccount: string;
|
|
50
|
+
/**
|
|
51
|
+
* The author's `AccountProof<DeviceCert>`, hex-encoded borsh.
|
|
52
|
+
*
|
|
53
|
+
* Proves the key that signed the warrant is a device of the account it names.
|
|
54
|
+
* The author sends only its own half; the relay attaches its own.
|
|
55
|
+
*/
|
|
56
|
+
authorProof: string;
|
|
57
|
+
/**
|
|
58
|
+
* The author device's ed25519 signing secret, hex (32 bytes).
|
|
59
|
+
*
|
|
60
|
+
* Never transmitted. It signs in this process and only the signature leaves,
|
|
61
|
+
* which is the whole reason a keyholder can author without a node.
|
|
62
|
+
*/
|
|
63
|
+
deviceSecret: string;
|
|
64
|
+
/** Where nonces come from. See {@link NonceSource} — a reset replays. */
|
|
65
|
+
nonces: NonceSource;
|
|
66
|
+
/**
|
|
67
|
+
* Seconds a minted warrant stays valid. Defaults to 300.
|
|
68
|
+
*
|
|
69
|
+
* Checked by the relay against its own clock and deliberately never by peers,
|
|
70
|
+
* so this bounds how long *this* request may sit in flight — not how long the
|
|
71
|
+
* network will accept the delta.
|
|
72
|
+
*/
|
|
73
|
+
ttlSeconds?: number;
|
|
74
|
+
/** Injected for tests and non-browser runtimes. Defaults to global `fetch`. */
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
/** What a relay says about its ability to run intents in one context. */
|
|
79
|
+
export interface RelayDescription {
|
|
80
|
+
/** The account a warrant must name as `executor`, hex. */
|
|
81
|
+
executorAccount: string;
|
|
82
|
+
/** Whether the relay holds `CAN_AUTHOR_ON_BEHALF` on the owning group. */
|
|
83
|
+
canAuthorOnBehalf: boolean;
|
|
84
|
+
/** The group whose admin grants that capability, hex. */
|
|
85
|
+
groupId: string;
|
|
86
|
+
}
|
|
87
|
+
/** Where an accepted intent landed. */
|
|
88
|
+
export interface IntentResult<T = unknown> {
|
|
89
|
+
/** The context's scope root after the run — did this change anything? */
|
|
90
|
+
rootHash: string;
|
|
91
|
+
/** The method's own return value. */
|
|
92
|
+
returns: T | null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Thrown when a relay refuses an intent, carrying which precondition failed.
|
|
96
|
+
*
|
|
97
|
+
* The distinction is the whole reason this type exists. A `403` from this
|
|
98
|
+
* endpoint means one of three unrelated things — no grant on the relay, the
|
|
99
|
+
* author is not a member, or the nonce was already spent — and they send a
|
|
100
|
+
* caller somewhere completely different: ask an admin, ask for an invitation,
|
|
101
|
+
* or just retry. Collapsing them into "forbidden" makes a retryable replay look
|
|
102
|
+
* like a permissions bug.
|
|
103
|
+
*/
|
|
104
|
+
export declare class IntentRefusedError extends Error {
|
|
105
|
+
/** The relay's own explanation, verbatim. */
|
|
106
|
+
readonly reason: string;
|
|
107
|
+
/** `true` when re-presenting the same intent under a *fresh* warrant may work. */
|
|
108
|
+
readonly retryable: boolean;
|
|
109
|
+
readonly status: number;
|
|
110
|
+
name: string;
|
|
111
|
+
constructor(
|
|
112
|
+
/** The relay's own explanation, verbatim. */
|
|
113
|
+
reason: string,
|
|
114
|
+
/** `true` when re-presenting the same intent under a *fresh* warrant may work. */
|
|
115
|
+
retryable: boolean, status: number);
|
|
116
|
+
}
|
|
117
|
+
export declare class RelayClient {
|
|
118
|
+
private readonly baseUrl;
|
|
119
|
+
private executorAccount;
|
|
120
|
+
private readonly config;
|
|
121
|
+
private readonly fetchImpl;
|
|
122
|
+
private readonly timeoutMs;
|
|
123
|
+
constructor(config: RelayClientConfig);
|
|
124
|
+
/**
|
|
125
|
+
* Ask the relay what it can do in `contextId`.
|
|
126
|
+
*
|
|
127
|
+
* Cheap and unauthenticated on a relay that serves delegated execution
|
|
128
|
+
* publicly, which is what makes it usable as a precondition check rather than
|
|
129
|
+
* as diagnostics after a failure.
|
|
130
|
+
*/
|
|
131
|
+
describe(contextId: string): Promise<RelayDescription>;
|
|
132
|
+
/**
|
|
133
|
+
* Mint a warrant for `method(args)` and present it to the relay.
|
|
134
|
+
*
|
|
135
|
+
* One intent, once: the warrant authorizes exactly this method and these
|
|
136
|
+
* arguments in exactly this context, and its nonce is spent by the network on
|
|
137
|
+
* apply. Nothing accumulates and nothing is reusable.
|
|
138
|
+
*/
|
|
139
|
+
execute<T = unknown>(contextId: string, method: string, argsJson?: unknown): Promise<IntentResult<T>>;
|
|
140
|
+
private json;
|
|
141
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write to a context through a relay, holding nothing but a signing key.
|
|
3
|
+
*
|
|
4
|
+
* # What this replaces
|
|
5
|
+
*
|
|
6
|
+
* `sdk.rpc.execute` asks a node to run a method *as itself*: it needs a node,
|
|
7
|
+
* that node needs to be a member, and the caller needs a credential on it. For
|
|
8
|
+
* a browser tab, a phone, or an agent, none of those three is available — the
|
|
9
|
+
* runtime cannot compile WASM against materialized state, never received the
|
|
10
|
+
* scope key that seals the deltas, and has no account on anybody's node.
|
|
11
|
+
*
|
|
12
|
+
* This client is the other path. The caller signs a warrant locally, the relay
|
|
13
|
+
* runs the method as the *author's* principal, and the resulting delta is
|
|
14
|
+
* attributed to the author's account and device — their replica slot, their
|
|
15
|
+
* membership, their name in the app. The relay's key signs the envelope and
|
|
16
|
+
* nothing else.
|
|
17
|
+
*
|
|
18
|
+
* # The three preconditions, and why they are checked in this order
|
|
19
|
+
*
|
|
20
|
+
* `describe()` answers the first two in one call, before anything is signed:
|
|
21
|
+
* the relay's executor account (which the warrant must name) and whether it
|
|
22
|
+
* holds `CAN_AUTHOR_ON_BEHALF` on the owning group. The third — that the
|
|
23
|
+
* author's account is a member — only the group knows, and it surfaces as a
|
|
24
|
+
* refusal.
|
|
25
|
+
*
|
|
26
|
+
* Checking before signing is not politeness. A warrant consumes a nonce from a
|
|
27
|
+
* monotonic per-device sequence, and one minted against the wrong executor is
|
|
28
|
+
* unspendable: the number is gone and the write never happened.
|
|
29
|
+
*/
|
|
30
|
+
import { signWarrant } from '../warrant/warrant.js';
|
|
31
|
+
import { HTTPError } from '../http-client/web-client.js';
|
|
32
|
+
/** How long a freshly minted warrant stays presentable, in seconds. */
|
|
33
|
+
const DEFAULT_TTL_SECONDS = 300;
|
|
34
|
+
/**
|
|
35
|
+
* Thrown when a relay refuses an intent, carrying which precondition failed.
|
|
36
|
+
*
|
|
37
|
+
* The distinction is the whole reason this type exists. A `403` from this
|
|
38
|
+
* endpoint means one of three unrelated things — no grant on the relay, the
|
|
39
|
+
* author is not a member, or the nonce was already spent — and they send a
|
|
40
|
+
* caller somewhere completely different: ask an admin, ask for an invitation,
|
|
41
|
+
* or just retry. Collapsing them into "forbidden" makes a retryable replay look
|
|
42
|
+
* like a permissions bug.
|
|
43
|
+
*/
|
|
44
|
+
export class IntentRefusedError extends Error {
|
|
45
|
+
constructor(
|
|
46
|
+
/** The relay's own explanation, verbatim. */
|
|
47
|
+
reason,
|
|
48
|
+
/** `true` when re-presenting the same intent under a *fresh* warrant may work. */
|
|
49
|
+
retryable, status) {
|
|
50
|
+
super(`relay refused the intent (HTTP ${status}): ${reason}`);
|
|
51
|
+
this.reason = reason;
|
|
52
|
+
this.retryable = retryable;
|
|
53
|
+
this.status = status;
|
|
54
|
+
this.name = 'IntentRefusedError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export class RelayClient {
|
|
58
|
+
constructor(config) {
|
|
59
|
+
this.config = config;
|
|
60
|
+
this.baseUrl = config.relayUrl.replace(/\/+$/, '');
|
|
61
|
+
this.executorAccount = config.executorAccount;
|
|
62
|
+
const injected = config.fetch;
|
|
63
|
+
this.fetchImpl = injected
|
|
64
|
+
? (input, init) => injected(input, init)
|
|
65
|
+
: (input, init) => globalThis.fetch(input, init);
|
|
66
|
+
this.timeoutMs = config.timeoutMs ?? 10000;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ask the relay what it can do in `contextId`.
|
|
70
|
+
*
|
|
71
|
+
* Cheap and unauthenticated on a relay that serves delegated execution
|
|
72
|
+
* publicly, which is what makes it usable as a precondition check rather than
|
|
73
|
+
* as diagnostics after a failure.
|
|
74
|
+
*/
|
|
75
|
+
async describe(contextId) {
|
|
76
|
+
const body = await this.json('GET', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`);
|
|
77
|
+
// Learned, not merely returned: a client that called `describe` should not
|
|
78
|
+
// then have to pass the account back in to `execute`.
|
|
79
|
+
this.executorAccount = body.data.executorAccount;
|
|
80
|
+
return body.data;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Mint a warrant for `method(args)` and present it to the relay.
|
|
84
|
+
*
|
|
85
|
+
* One intent, once: the warrant authorizes exactly this method and these
|
|
86
|
+
* arguments in exactly this context, and its nonce is spent by the network on
|
|
87
|
+
* apply. Nothing accumulates and nothing is reusable.
|
|
88
|
+
*/
|
|
89
|
+
async execute(contextId, method, argsJson = {}) {
|
|
90
|
+
const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
|
|
91
|
+
const nonce = await this.config.nonces.next();
|
|
92
|
+
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
93
|
+
const warrant = await signWarrant({
|
|
94
|
+
context: contextId,
|
|
95
|
+
authorAccount: this.config.authorAccount,
|
|
96
|
+
executor,
|
|
97
|
+
method,
|
|
98
|
+
argsJson,
|
|
99
|
+
nonce,
|
|
100
|
+
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
101
|
+
deviceSecret: this.config.deviceSecret,
|
|
102
|
+
});
|
|
103
|
+
const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
|
|
104
|
+
return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
|
|
105
|
+
}
|
|
106
|
+
async json(method, path, body) {
|
|
107
|
+
const headers = { Accept: 'application/json' };
|
|
108
|
+
if (body !== undefined)
|
|
109
|
+
headers['Content-Type'] = 'application/json';
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
115
|
+
method,
|
|
116
|
+
headers,
|
|
117
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
118
|
+
signal: controller.signal,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
throw new HTTPError(0, err instanceof Error ? err.name : 'NetworkError', `${this.baseUrl}${path}`, new Headers(), err instanceof Error ? err.message : String(err));
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
const text = await response.text().catch(() => '');
|
|
129
|
+
// 400 and 403 are both caller preconditions and core separates them
|
|
130
|
+
// deliberately: 400 means re-sending the same bytes cannot help, 403
|
|
131
|
+
// means the bytes are genuine but authority is missing. Only the spent
|
|
132
|
+
// nonce is worth retrying, and only under a fresh warrant.
|
|
133
|
+
if (response.status === 400 || response.status === 403) {
|
|
134
|
+
const reason = extractReason(text);
|
|
135
|
+
throw new IntentRefusedError(reason, /nonce/i.test(reason), response.status);
|
|
136
|
+
}
|
|
137
|
+
throw new HTTPError(response.status, response.statusText, `${this.baseUrl}${path}`, response.headers, text);
|
|
138
|
+
}
|
|
139
|
+
return (await response.json());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Pull the relay's explanation out of its error body, or fall back to the raw text. */
|
|
143
|
+
function extractReason(bodyText) {
|
|
144
|
+
if (!bodyText)
|
|
145
|
+
return 'no reason given';
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(bodyText);
|
|
148
|
+
for (const candidate of [parsed.error, parsed.message]) {
|
|
149
|
+
if (typeof candidate === 'string' && candidate)
|
|
150
|
+
return candidate;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Not JSON — the text itself is the best available explanation.
|
|
155
|
+
}
|
|
156
|
+
return bodyText;
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calimero-network/mero-js",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.3.0",
|
|
4
4
|
"description": "Pure JavaScript SDK for Calimero",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"typecheck": "tsc --noEmit",
|
|
51
51
|
"typecheck:contract": "tsc --noEmit -p tsconfig.contract.json",
|
|
52
52
|
"typecheck:consumer": "tsc --noEmit -p tsconfig.consumer.json",
|
|
53
|
+
"typecheck:e2e": "tsc --noEmit -p tsconfig.e2e.json",
|
|
53
54
|
"test:contract": "vitest run --config vitest.config.ts src/__contract__",
|
|
54
55
|
"tsc": "tsc -b",
|
|
55
56
|
"clean": "pnpm rimraf dist",
|