@calimero-network/mero-js 18.2.0 → 19.0.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 +34 -0
- 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 +664 -30
- package/dist/index.cjs.map +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/index.mjs +664 -30
- 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/dist/warrant/warrant.d.ts +89 -10
- package/dist/warrant/warrant.js +109 -12
- 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
|
+
}
|
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* so "a member with no node" in practice meant "a member with a node it is not
|
|
8
8
|
* allowed to use for anything else".
|
|
9
9
|
*
|
|
10
|
-
* **No dependencies, and that is not incidental.** The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* **No dependencies, and that is not incidental.** The primitives are all built
|
|
11
|
+
* in: `crypto.subtle.digest('SHA-256')` for the hash, `crypto.subtle` Ed25519
|
|
12
|
+
* for the signature, and `DataView` for the integers.
|
|
13
|
+
*
|
|
14
|
+
* Warrant v2 (core#3933) gave up the fixed-width encoding that used to make this
|
|
15
|
+
* pure concatenation: `method` travels in the clear as a string and each cited
|
|
16
|
+
* head list is a vector, so three borsh `u32` counts now sit inside the bytes.
|
|
17
|
+
* Two widths to keep straight, and they are not the same: the counts **inside
|
|
18
|
+
* the encoding** are `u32`, while the counts the **signing preimage** hashes are
|
|
19
|
+
* `u64`. Same numbers, different serialization — reusing one for the other
|
|
20
|
+
* produces a warrant that is well-formed and verifies nowhere.
|
|
15
21
|
*
|
|
16
22
|
* **The byte contract is pinned in core**, at
|
|
17
23
|
* `crates/account/src/tests/warrant_wire_fixture.rs`. The domain strings below
|
|
@@ -37,7 +43,29 @@ export interface WarrantInput {
|
|
|
37
43
|
authorAccount: string;
|
|
38
44
|
/** The relay authorised to act, hex. An account, not a key. */
|
|
39
45
|
executor: string;
|
|
40
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The application build this warrant is signed against, hex (32 bytes).
|
|
48
|
+
*
|
|
49
|
+
* Pins the code rather than a version string, so a relay cannot wait for an
|
|
50
|
+
* upgrade that widens what `method` does and then spend a warrant signed
|
|
51
|
+
* against the narrower one. Read it from the context
|
|
52
|
+
* (`GET /admin-api/contexts/{id}` → `applicationId`); a value guessed here
|
|
53
|
+
* pins the wrong build, which is worse than not pinning.
|
|
54
|
+
*
|
|
55
|
+
* Defaults to 32 zero bytes, matching `merod account sign-warrant`'s
|
|
56
|
+
* `--app-version` default. Nothing verifies the field yet — core#3933 landed
|
|
57
|
+
* the field set ahead of its enforcement so a signer is written once against
|
|
58
|
+
* the final bytes — but a warrant minted with the default will be refused once
|
|
59
|
+
* pinning lands, so pass the real value for anything meant to outlive it.
|
|
60
|
+
*/
|
|
61
|
+
appVersion?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The method the relay may run.
|
|
64
|
+
*
|
|
65
|
+
* Carried in the clear as of v2, so a peer can select a per-method write-set
|
|
66
|
+
* without reversing a hash against the app's ABI. The *arguments* are still
|
|
67
|
+
* committed to rather than carried — see {@link intentHash}.
|
|
68
|
+
*/
|
|
41
69
|
method: string;
|
|
42
70
|
/** Its arguments, as the JSON the guest will receive. */
|
|
43
71
|
argsJson: unknown;
|
|
@@ -49,6 +77,25 @@ export interface WarrantInput {
|
|
|
49
77
|
* account-scoped sequence would have them refusing each other's warrants.
|
|
50
78
|
*/
|
|
51
79
|
nonce: number | bigint;
|
|
80
|
+
/**
|
|
81
|
+
* Account-log heads this author saw when signing, each hex (32 bytes).
|
|
82
|
+
*
|
|
83
|
+
* Empty by default, and empty is the honest answer for a client that tracks
|
|
84
|
+
* no log — a fabricated view is worse than none. At most
|
|
85
|
+
* {@link MAX_CITED_HEADS}.
|
|
86
|
+
*/
|
|
87
|
+
accountHeads?: string[];
|
|
88
|
+
/**
|
|
89
|
+
* Governance heads this author's view descended from, each hex (32 bytes).
|
|
90
|
+
*
|
|
91
|
+
* **Where these come from decides whether they mean anything.** A floor the
|
|
92
|
+
* relay supplied is a floor the relay chose: a relay that withholds a
|
|
93
|
+
* governance op can hand you a stale view and collect a warrant citing it.
|
|
94
|
+
* Take the value from the account's own devices or a co-signing peer outside
|
|
95
|
+
* the relay's operator. As it stands this detects an honest relay's staleness
|
|
96
|
+
* and does not constrain a dishonest one.
|
|
97
|
+
*/
|
|
98
|
+
governanceFloor?: string[];
|
|
52
99
|
/** Unix seconds after which the relay must refuse it. */
|
|
53
100
|
notAfter: number | bigint;
|
|
54
101
|
/**
|
|
@@ -69,9 +116,41 @@ export declare function intentHash(method: string, argsJson: unknown): Promise<U
|
|
|
69
116
|
/**
|
|
70
117
|
* Sign a warrant and return it hex-encoded, ready for `performIntent`.
|
|
71
118
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* was signed.
|
|
119
|
+
* Returns the encoding rather than an object because the signature covers
|
|
120
|
+
* exactly these bytes: a caller that rebuilt the fields from JSON would have a
|
|
121
|
+
* second spelling able to disagree with what was signed.
|
|
76
122
|
*/
|
|
77
123
|
export declare function signWarrant(input: WarrantInput): Promise<string>;
|
|
124
|
+
/** A warrant's fields, as hex, decoded from the wire encoding. */
|
|
125
|
+
export interface WarrantFields {
|
|
126
|
+
context: string;
|
|
127
|
+
authorAccount: string;
|
|
128
|
+
/** The author device key `signWarrant` derived from the secret. */
|
|
129
|
+
deviceKey: string;
|
|
130
|
+
executor: string;
|
|
131
|
+
appVersion: string;
|
|
132
|
+
method: string;
|
|
133
|
+
intentHash: string;
|
|
134
|
+
accountHeads: string[];
|
|
135
|
+
governanceFloor: string[];
|
|
136
|
+
/** u64 little-endian, as it sits on the wire. */
|
|
137
|
+
nonce: string;
|
|
138
|
+
/** u64 little-endian, as it sits on the wire. */
|
|
139
|
+
notAfter: string;
|
|
140
|
+
signature: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Decode a hex warrant back into its fields.
|
|
144
|
+
*
|
|
145
|
+
* Exists because v2's offsets are no longer constants: `method` is a string and
|
|
146
|
+
* the head lists are vectors, so everything after `appVersion` moves with the
|
|
147
|
+
* method's length. Anything reading a warrant by fixed offset — a test, a client
|
|
148
|
+
* inspecting one it was handed — silently reads the wrong field instead of
|
|
149
|
+
* failing, which is how the three call sites that did so passed while comparing
|
|
150
|
+
* the method's length prefix to a nonce.
|
|
151
|
+
*
|
|
152
|
+
* One copy of the layout, beside the encoder that produces it, so the two cannot
|
|
153
|
+
* drift. Internal: not exported from the package root, because a client that
|
|
154
|
+
* needs a warrant's fields has the input it signed.
|
|
155
|
+
*/
|
|
156
|
+
export declare function parseWarrant(warrant: string): WarrantFields;
|