@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/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,2 @@
1
+ export { RelayClient, IntentRefusedError } from './relay-client.js';
2
+ export { createMemoryNonceSource, createLocalStorageNonceSource } 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 three primitives are all
11
- * built in: `crypto.subtle.digest('SHA-256')` for the hash, `crypto.subtle`
12
- * Ed25519 for the signature, and `DataView.setBigUint64` for the two u64s. The
13
- * warrant's every field is fixed-width, so its encoding is concatenation — there
14
- * is no borsh here because none is needed.
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
- /** The method the relay may run. */
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
- * The 240 bytes are the canonical form: the signature covers exactly them, which
73
- * is why this returns the encoding rather than an object. A caller that rebuilt
74
- * the fields from JSON would have a second spelling able to disagree with what
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;