@calimero-network/mero-js 22.7.0 → 22.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account/account.d.ts +99 -4
- package/dist/account/account.js +0 -0
- package/dist/account/index.d.ts +6 -2
- package/dist/account/index.js +3 -1
- package/dist/admin-api/admin-client.d.ts +154 -2
- package/dist/admin-api/admin-client.js +255 -1
- package/dist/admin-api/admin-types.d.ts +193 -5
- package/dist/cloud/cloud-client.d.ts +38 -8
- package/dist/cloud/cloud-client.js +38 -21
- package/dist/cloud/connect.d.ts +101 -2
- package/dist/cloud/connect.js +84 -8
- package/dist/cloud/enrol-redirect.d.ts +195 -0
- package/dist/cloud/enrol-redirect.js +236 -0
- package/dist/cloud/index.d.ts +6 -2
- package/dist/cloud/index.js +7 -1
- package/dist/cloud/link-redirect.d.ts +85 -0
- package/dist/cloud/link-redirect.js +105 -0
- package/dist/crypto/internal.d.ts +15 -0
- package/dist/crypto/internal.js +18 -0
- package/dist/device-cert/device-cert.d.ts +96 -2
- package/dist/device-cert/device-cert.js +101 -11
- package/dist/device-cert/device-scope.d.ts +58 -0
- package/dist/device-cert/device-scope.js +76 -0
- package/dist/device-cert/index.d.ts +4 -2
- package/dist/device-cert/index.js +2 -1
- package/dist/events/sse.d.ts +22 -2
- package/dist/events/sse.js +20 -11
- package/dist/http-client/http-types.d.ts +18 -0
- package/dist/http-client/web-client.js +21 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +1955 -496
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +21 -2
- package/dist/index.mjs +1955 -496
- package/dist/index.mjs.map +4 -4
- package/dist/invitation/index.d.ts +2 -0
- package/dist/invitation/index.js +2 -0
- package/dist/invitation/invitation.d.ts +106 -0
- package/dist/invitation/invitation.js +104 -0
- package/dist/login/login.d.ts +11 -1
- package/dist/login/login.js +5 -4
- package/dist/login/session.d.ts +21 -5
- package/dist/login/session.js +7 -15
- package/dist/namespace-op/namespace-op.d.ts +14 -2
- package/dist/namespace-op/namespace-op.js +13 -19
- package/dist/relay/index.d.ts +2 -2
- package/dist/relay/index.js +1 -1
- package/dist/relay/nonce-source.d.ts +135 -0
- package/dist/relay/nonce-source.js +187 -19
- package/dist/relay/relay-client.d.ts +60 -5
- package/dist/relay/relay-client.js +84 -4
- package/dist/relay-attestation/attest-node-key.d.ts +66 -0
- package/dist/relay-attestation/attest-node-key.js +110 -0
- package/dist/relay-attestation/index.d.ts +1 -0
- package/dist/relay-attestation/index.js +1 -0
- package/dist/request-proof/authorizer.d.ts +48 -0
- package/dist/request-proof/authorizer.js +65 -0
- package/dist/request-proof/index.d.ts +4 -0
- package/dist/request-proof/index.js +2 -0
- package/dist/request-proof/request-proof.d.ts +56 -0
- package/dist/request-proof/request-proof.js +111 -0
- package/dist/rpc/index.d.ts +21 -0
- package/dist/rpc/index.js +20 -0
- package/dist/transport/index.d.ts +6 -0
- package/dist/transport/index.js +3 -0
- package/dist/transport/mero-client.d.ts +286 -0
- package/dist/transport/mero-client.js +379 -0
- package/dist/transport/relay-observer.d.ts +152 -0
- package/dist/transport/relay-observer.js +173 -0
- package/dist/transport/relay-transport.d.ts +73 -0
- package/dist/transport/relay-transport.js +94 -0
- package/dist/transport/types.d.ts +100 -0
- package/dist/transport/types.js +32 -0
- package/package.json +1 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One client, two transports — chosen once, at construction.
|
|
3
|
+
*
|
|
4
|
+
* # The problem this exists for
|
|
5
|
+
*
|
|
6
|
+
* An app that wants to move from "the user has a node" to "the user has a key
|
|
7
|
+
* and a relay" had to rewrite itself, because the two write paths are different
|
|
8
|
+
* objects with different method shapes. That makes the transport a property of
|
|
9
|
+
* the *application source*, when it is really a property of the connection: the
|
|
10
|
+
* same screen, the same hook, the same `execute` call wants to work either way.
|
|
11
|
+
*
|
|
12
|
+
* `createMeroClient` takes the decision at construction and nowhere else. After
|
|
13
|
+
* that, `client.rpc.execute({ contextId, method, argsJson })` is the same call
|
|
14
|
+
* against a node and against a relay — same arguments, same return type. The
|
|
15
|
+
* node shape is the one both speak, because shipped apps already write it and
|
|
16
|
+
* the alternative would be a breaking change wearing an abstraction's clothes.
|
|
17
|
+
*
|
|
18
|
+
* # Defaulting to the node is a compatibility guarantee
|
|
19
|
+
*
|
|
20
|
+
* `createMeroClient({ baseUrl })` is `createMeroJs({ baseUrl })` with a facade
|
|
21
|
+
* in front. No `transport` field means `'node'`, so nothing an existing app
|
|
22
|
+
* does can accidentally select the relay.
|
|
23
|
+
*
|
|
24
|
+
* # Events work on both transports, given one input
|
|
25
|
+
*
|
|
26
|
+
* A relay is an ordinary node, so it serves `/sse` and `/ws` to a credential it
|
|
27
|
+
* issued, and a keyholder can obtain one by logging in with its account proof.
|
|
28
|
+
* `client.events` and `client.ws` are therefore the same call on both
|
|
29
|
+
* transports — provided the caller supplies the relay node's device signing
|
|
30
|
+
* key, which must be learned out of band and is the one thing that cannot be
|
|
31
|
+
* inferred. See `./relay-observer.ts` for why, and mdma #312 for the hosted
|
|
32
|
+
* case. Without it, {@link MeroClient.canSubscribe} is `false` and the
|
|
33
|
+
* accessors throw naming exactly that.
|
|
34
|
+
*
|
|
35
|
+
* # What is deliberately NOT unified
|
|
36
|
+
*
|
|
37
|
+
* Reads and admin. A relay serves exactly two routes to a caller with no
|
|
38
|
+
* credential — `GET` and `POST /admin-api/contexts/:id/intents` — and while a
|
|
39
|
+
* session obtained by logging in carries `context:query`, `sdk.admin` wants
|
|
40
|
+
* `admin`, which the `account_proof` provider does not grant and a keyholder
|
|
41
|
+
* should never ask for.
|
|
42
|
+
*
|
|
43
|
+
* So those accessors throw on a relay client, with a message naming what is
|
|
44
|
+
* missing, rather than returning something that quietly connects somewhere
|
|
45
|
+
* else. Falling back to "some node" would be the worst of the options: the app
|
|
46
|
+
* would observe a node it never chose, under a credential it never granted, and
|
|
47
|
+
* find out only when the two disagreed. `canSubscribe` and `transport` are
|
|
48
|
+
* there so an app can branch *before* touching any of it.
|
|
49
|
+
*/
|
|
50
|
+
import { MeroJs } from '../mero-js.js';
|
|
51
|
+
import { RelayClient } from '../relay/relay-client.js';
|
|
52
|
+
import { RelayTransport } from './relay-transport.js';
|
|
53
|
+
import { RelayObserver, defaultAudience } from './relay-observer.js';
|
|
54
|
+
import { createAdminApiClient, createAdminApiClientFromHttpClient } from '../admin-api/index.js';
|
|
55
|
+
import { createHttpClient } from '../http-client/index.js';
|
|
56
|
+
import { createProofAuthorizer } from '../request-proof/index.js';
|
|
57
|
+
import { SseClient } from '../events/sse.js';
|
|
58
|
+
/** Message shared by every node-only accessor, so the reason is stated once. */
|
|
59
|
+
function relayHasNo(surface, why) {
|
|
60
|
+
return new Error(`this client was constructed with the relay transport, which has no ${surface}: ${why}. ` +
|
|
61
|
+
'Construct a node client (createMeroClient({ baseUrl })) for that surface — ' +
|
|
62
|
+
'nothing here will silently connect to a node you did not choose.');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The one thing a relay client can be missing, named precisely.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately not the same message as {@link relayHasNo}: this is not "the
|
|
68
|
+
* relay transport has no such surface". The relay has the surface — it serves
|
|
69
|
+
* `/sse` and `/ws`, and the `account_proof` provider grants
|
|
70
|
+
* `context:subscribe`. What is missing is a single 32-byte value, and a caller
|
|
71
|
+
* who can obtain it needs to be told that, not told to go and build a node
|
|
72
|
+
* client.
|
|
73
|
+
*/
|
|
74
|
+
function missingNodeKey(surface) {
|
|
75
|
+
return new Error(`this relay-transport client cannot open ${surface}: it was given no relay node key. ` +
|
|
76
|
+
'A relay is an ordinary node and does serve /sse and /ws — but the login statement must be ' +
|
|
77
|
+
"signed against the node's device signing key, learned out of band (never read from the node, " +
|
|
78
|
+
'and never derived from its peerId). Pass it as `observe: { nodeKey }`. ' +
|
|
79
|
+
'For a cloud-hosted relay the cloud does not publish it yet — see mdma #312.');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve an observe config against the relay client that will do the writing.
|
|
83
|
+
*
|
|
84
|
+
* Returns `null` for "no key, so no observation" — the single place that
|
|
85
|
+
* decision is made, so `canSubscribe`, `events` and `ws` cannot disagree about
|
|
86
|
+
* it. The signer and proof default to the relay's own: the same device that
|
|
87
|
+
* authors is the device that observes, and taking a second copy from the caller
|
|
88
|
+
* is how the two come to name different keys.
|
|
89
|
+
*/
|
|
90
|
+
function buildObserver(relay, observe) {
|
|
91
|
+
const nodeKey = observe?.nodeKey;
|
|
92
|
+
if (!nodeKey)
|
|
93
|
+
return null;
|
|
94
|
+
return new RelayObserver({
|
|
95
|
+
nodeUrl: observe?.nodeUrl ?? relay.relayUrl,
|
|
96
|
+
nodeKey,
|
|
97
|
+
accountProof: observe?.accountProof ?? relay.authorProof,
|
|
98
|
+
audience: observe?.audience ?? defaultAudience(),
|
|
99
|
+
signer: observe?.signer ? async () => observe.signer : () => relay.authorSigner(),
|
|
100
|
+
clientName: observe?.clientName,
|
|
101
|
+
ttlSeconds: observe?.ttlSeconds,
|
|
102
|
+
fetch: observe?.fetch,
|
|
103
|
+
timeoutMs: observe?.timeoutMs,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A connection whose transport was decided at construction.
|
|
108
|
+
*
|
|
109
|
+
* Everything reachable on a node client is reachable in exactly the shape
|
|
110
|
+
* `MeroJs` exposes it, by delegation rather than re-implementation — this adds
|
|
111
|
+
* no behaviour to the node path and cannot drift from it.
|
|
112
|
+
*/
|
|
113
|
+
export class MeroClient {
|
|
114
|
+
constructor(config) {
|
|
115
|
+
/** The relay's event session, or `null` on a node client / with no node key. */
|
|
116
|
+
this.relayObserver = null;
|
|
117
|
+
/** Admin reads against the relay, when a session or a proof was supplied. */
|
|
118
|
+
this.relayAdmin = null;
|
|
119
|
+
/**
|
|
120
|
+
* Events off the relay authenticated by a request proof, built lazily.
|
|
121
|
+
*
|
|
122
|
+
* Separate from {@link relayObserver} rather than a mode inside it: the
|
|
123
|
+
* observer's whole subject is a session addressed to a node key, and a proof
|
|
124
|
+
* has neither. Holding the authorizer instead of the client keeps construction
|
|
125
|
+
* free of the first signature.
|
|
126
|
+
*/
|
|
127
|
+
this.proofAuthorizer = null;
|
|
128
|
+
this.relayUrl = null;
|
|
129
|
+
this.proofEvents = null;
|
|
130
|
+
if (config.transport === 'relay') {
|
|
131
|
+
this.transport = 'relay';
|
|
132
|
+
this.nodeClient = null;
|
|
133
|
+
const relay = config.relay instanceof RelayClient ? config.relay : new RelayClient(config.relay);
|
|
134
|
+
this.relayObserver = buildObserver(relay, config.observe);
|
|
135
|
+
this.rpcTransport = new RelayTransport(relay, this.relayObserver);
|
|
136
|
+
this.relayUrl = relay.relayUrl;
|
|
137
|
+
if (config.proof) {
|
|
138
|
+
this.proofAuthorizer = createProofAuthorizer(config.proof);
|
|
139
|
+
}
|
|
140
|
+
// A session wins where both are present. Not a preference between
|
|
141
|
+
// credentials but the node's own precedence: it answers a request carrying
|
|
142
|
+
// a token by that token and consults a proof only when there is none, so
|
|
143
|
+
// sending both would authenticate by the session anyway — and a client
|
|
144
|
+
// whose reads and whose events disagreed about which identity they ran as
|
|
145
|
+
// is the one outcome worth ruling out here.
|
|
146
|
+
if (config.session) {
|
|
147
|
+
const token = config.session;
|
|
148
|
+
this.relayAdmin = createAdminApiClient({
|
|
149
|
+
baseUrl: relay.relayUrl,
|
|
150
|
+
getAuthToken: typeof token === 'string' ? async () => token : token,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (this.proofAuthorizer) {
|
|
154
|
+
const authorizeRequest = this.proofAuthorizer;
|
|
155
|
+
this.relayAdmin = createAdminApiClientFromHttpClient(createHttpClient({
|
|
156
|
+
fetch: (url, init) => globalThis.fetch(url, init),
|
|
157
|
+
baseUrl: relay.relayUrl,
|
|
158
|
+
authorizeRequest,
|
|
159
|
+
}), { baseUrl: relay.relayUrl });
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.transport = 'node';
|
|
164
|
+
// `transport` is a marker this class reads and `MeroJs` has never seen.
|
|
165
|
+
// Stripped rather than passed through, so nothing downstream has to know
|
|
166
|
+
// that the field exists.
|
|
167
|
+
const { transport: _ignored, ...nodeConfig } = config;
|
|
168
|
+
this.nodeClient = new MeroJs(nodeConfig);
|
|
169
|
+
this.rpcTransport = this.nodeClient.rpc;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The write surface. Identical on both transports — this is the whole point.
|
|
173
|
+
*
|
|
174
|
+
* On a node this IS the `RpcClient` a `MeroJs` would have handed out, not a
|
|
175
|
+
* wrapper around it, so `client.rpc` and `client.node.rpc` are the same
|
|
176
|
+
* object.
|
|
177
|
+
*/
|
|
178
|
+
get rpc() {
|
|
179
|
+
return this.rpcTransport;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Whether `events` / `ws` will work on this client.
|
|
183
|
+
*
|
|
184
|
+
* Always `true` on the node transport. On the relay transport it says whether
|
|
185
|
+
* a node key was supplied — the relay can be observed, but only by a caller
|
|
186
|
+
* who can name the key the login statement is signed against.
|
|
187
|
+
*/
|
|
188
|
+
get canSubscribe() {
|
|
189
|
+
return this.rpcTransport.canSubscribe || this.proofAuthorizer !== null;
|
|
190
|
+
}
|
|
191
|
+
/** The underlying `MeroJs`. Node transport only. */
|
|
192
|
+
get node() {
|
|
193
|
+
if (!this.nodeClient) {
|
|
194
|
+
throw relayHasNo('node connection', 'it holds a signing key and a relay URL, not a node');
|
|
195
|
+
}
|
|
196
|
+
return this.nodeClient;
|
|
197
|
+
}
|
|
198
|
+
/** The underlying `RelayClient`, for `describe()`. Relay transport only. */
|
|
199
|
+
get relay() {
|
|
200
|
+
if (!(this.rpcTransport instanceof RelayTransport)) {
|
|
201
|
+
throw new Error('this client was constructed with the node transport and has no relay');
|
|
202
|
+
}
|
|
203
|
+
return this.rpcTransport.client;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Admin API. Node transport only.
|
|
207
|
+
*
|
|
208
|
+
* Two different things live behind this accessor today, and only one of them
|
|
209
|
+
* is genuinely unavailable through a relay.
|
|
210
|
+
*
|
|
211
|
+
* The **mutations** — `createNamespace`, `setMemberCapabilities`,
|
|
212
|
+
* `upgradeGroup` and the rest — are operator actions on somebody else's
|
|
213
|
+
* machine. A keyholder does not get those, and should not; admin is for
|
|
214
|
+
* admins.
|
|
215
|
+
*
|
|
216
|
+
* The **reads** are a different matter. `getContext`, `getContexts`,
|
|
217
|
+
* `getApplication`, `listNamespaces`, `getBlob` are an app asking what it is
|
|
218
|
+
* looking at, not an operator administering anything. They sit under
|
|
219
|
+
* `/admin-api/` for historical reasons, and a delegated session is refused
|
|
220
|
+
* them (measured: `403`, while the same session reads context state through
|
|
221
|
+
* `POST /contexts/{ctx}/query` and writes through `/intents` quite happily).
|
|
222
|
+
*
|
|
223
|
+
* So this is the one place where "the same code runs on both transports"
|
|
224
|
+
* does not hold yet, and it is a missing surface rather than a missing
|
|
225
|
+
* permission. A transport-independent read API is designed but not built; the
|
|
226
|
+
* throw below is deliberate until it exists, because the alternative — quietly
|
|
227
|
+
* reaching for a node the caller did not choose — is worse than a clear stop.
|
|
228
|
+
*/
|
|
229
|
+
/**
|
|
230
|
+
* The admin surface.
|
|
231
|
+
*
|
|
232
|
+
* On a node client this is the node's own. On a relay client it exists only
|
|
233
|
+
* when a `session` was supplied, and it is **the relay's admin API
|
|
234
|
+
* authenticated as the account** — so the caller-scoped reads answer:
|
|
235
|
+
* `getContexts` and `listNamespaces` return this account's own, because
|
|
236
|
+
* `account_proof` grants `context:list-own` and `namespace:list-own`.
|
|
237
|
+
*
|
|
238
|
+
* Mutations are not filtered out here, deliberately. A delegated session does
|
|
239
|
+
* not carry the grants they require, so the node refuses them with a `403`
|
|
240
|
+
* that names the missing permission — which is a better answer than a
|
|
241
|
+
* client-side guess, and keeps the policy in the one place that owns it.
|
|
242
|
+
*/
|
|
243
|
+
get admin() {
|
|
244
|
+
if (this.nodeClient)
|
|
245
|
+
return this.nodeClient.admin;
|
|
246
|
+
if (this.relayAdmin)
|
|
247
|
+
return this.relayAdmin;
|
|
248
|
+
throw new Error('this relay-transport client has no admin surface because it was given neither a `proof` ' +
|
|
249
|
+
'nor a `session`. A warrant authorises one intent and is not a credential for reading. ' +
|
|
250
|
+
'The caller-scoped reads (getContexts, listNamespaces) want one of the other two: pass ' +
|
|
251
|
+
'`proof: { credential, deviceSecret }` — the device cert you already hold, which needs ' +
|
|
252
|
+
'no login and no node key — or a `session` minted with `login()`. Context STATE reads ' +
|
|
253
|
+
'work through execute/query either way.');
|
|
254
|
+
}
|
|
255
|
+
/** Auth API. Node transport only — a relay issues no node credential. */
|
|
256
|
+
get auth() {
|
|
257
|
+
return this.node.auth;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* SSE events — on **both** transports.
|
|
261
|
+
*
|
|
262
|
+
* On a relay client this is an `SseClient` against the relay node, fed by a
|
|
263
|
+
* session minted with the author's account proof. The session is minted
|
|
264
|
+
* lazily, on the first token the stream asks for, so reading this getter
|
|
265
|
+
* performs no network call.
|
|
266
|
+
*
|
|
267
|
+
* Throws only when the relay client was given no node key: the one input
|
|
268
|
+
* that cannot be inferred. See {@link missingNodeKey}.
|
|
269
|
+
*/
|
|
270
|
+
get events() {
|
|
271
|
+
if (this.nodeClient)
|
|
272
|
+
return this.nodeClient.events;
|
|
273
|
+
if (this.relayObserver)
|
|
274
|
+
return this.relayObserver.events;
|
|
275
|
+
if (this.proofAuthorizer && this.relayUrl) {
|
|
276
|
+
// Built on first use and then kept: a second `SseClient` would open a
|
|
277
|
+
// second stream and split the subscriptions across two sessions, so the
|
|
278
|
+
// caller would see some of its events on an object it no longer holds.
|
|
279
|
+
this.proofEvents ?? (this.proofEvents = new SseClient({
|
|
280
|
+
baseUrl: this.relayUrl,
|
|
281
|
+
authorize: this.proofAuthorizer,
|
|
282
|
+
}));
|
|
283
|
+
return this.proofEvents;
|
|
284
|
+
}
|
|
285
|
+
throw missingNodeKey('SSE events');
|
|
286
|
+
}
|
|
287
|
+
/** WebSocket events, on both transports. @experimental — prefer `events`. */
|
|
288
|
+
get ws() {
|
|
289
|
+
if (this.nodeClient)
|
|
290
|
+
return this.nodeClient.ws;
|
|
291
|
+
if (this.relayObserver)
|
|
292
|
+
return this.relayObserver.ws;
|
|
293
|
+
if (this.proofAuthorizer) {
|
|
294
|
+
// Not a missing credential: a proof DOES open `/ws` — measured, `101`
|
|
295
|
+
// against a node built from core `aecba573b`. What is missing is a way to
|
|
296
|
+
// carry it, and that is the browser's. `WebSocket` cannot set a request
|
|
297
|
+
// header, and the guard reads the proof from a header only; the `?token=`
|
|
298
|
+
// query parameter this client uses for a session exists for exactly that
|
|
299
|
+
// reason and has no proof equivalent yet.
|
|
300
|
+
throw new Error('this client authenticates with a request proof, which cannot open a WebSocket from a ' +
|
|
301
|
+
"browser: `WebSocket` cannot set the X-Calimero-Proof header, and core's guard reads " +
|
|
302
|
+
'the proof from a header only (the ?token= query parameter is the session equivalent ' +
|
|
303
|
+
'and has no proof counterpart). Use `client.events` — SSE is fetch-based, carries the ' +
|
|
304
|
+
'header, and is the recommended transport anyway.');
|
|
305
|
+
}
|
|
306
|
+
throw missingNodeKey('WebSocket events');
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Ephemeral presence. Node transport only.
|
|
310
|
+
*
|
|
311
|
+
* Not extended to the relay even though a logged-in session could reach both
|
|
312
|
+
* halves of it mechanically. Publishing resolves the author from an *owned
|
|
313
|
+
* context identity on the node*, and whether a delegated session has one has
|
|
314
|
+
* not been established — a presence client that silently published nothing,
|
|
315
|
+
* or published as the wrong identity, is worse than one that says it is not
|
|
316
|
+
* available. The read half is not lost: presence arrives on `client.events`
|
|
317
|
+
* like any other context event, and can be filtered there.
|
|
318
|
+
*
|
|
319
|
+
* The rejected alternative was to expose it over the relay session and let
|
|
320
|
+
* `set` fail at the node. That trades a clear construction-time answer for a
|
|
321
|
+
* runtime failure inside a UI that has already rendered.
|
|
322
|
+
*/
|
|
323
|
+
get ephemeral() {
|
|
324
|
+
if (!this.nodeClient) {
|
|
325
|
+
throw relayHasNo('ephemeral presence', 'publishing resolves the author from an owned context identity on the node, which a delegated session is not known to have — read presence off `client.events` instead');
|
|
326
|
+
}
|
|
327
|
+
return this.nodeClient.ephemeral;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Calimero Cloud.
|
|
331
|
+
*
|
|
332
|
+
* Available on both transports, because it is neither: the cloud session is
|
|
333
|
+
* independent of the node credential and of the relay's signing key, and a
|
|
334
|
+
* relay-transport app still needs it to list namespaces and manage billing.
|
|
335
|
+
*/
|
|
336
|
+
get cloud() {
|
|
337
|
+
if (!this.nodeClient) {
|
|
338
|
+
throw relayHasNo('bundled cloud client', 'a relay client is built from a cloud connection rather than owning one — reach it through the `cloud` field that `connectCloud` returns, or construct a `CloudClient` directly');
|
|
339
|
+
}
|
|
340
|
+
return this.nodeClient.cloud;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Release whatever the transport is holding.
|
|
344
|
+
*
|
|
345
|
+
* Safe on a relay client, where there is nothing open to close — a caller
|
|
346
|
+
* that tears down on unmount should not have to know which transport it got.
|
|
347
|
+
*/
|
|
348
|
+
close() {
|
|
349
|
+
this.nodeClient?.close();
|
|
350
|
+
// The observer and the proof stream, not the relay: a relay client holds
|
|
351
|
+
// nothing open. Tearing down on unmount must not have to know which
|
|
352
|
+
// transport — or which credential — it got.
|
|
353
|
+
this.relayObserver?.close();
|
|
354
|
+
this.proofEvents?.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Build a client for the chosen transport. Node unless told otherwise.
|
|
359
|
+
*
|
|
360
|
+
* ```ts
|
|
361
|
+
* // unchanged, and still the default
|
|
362
|
+
* const client = createMeroClient({ baseUrl: 'http://localhost:2428' });
|
|
363
|
+
*
|
|
364
|
+
* // the same call sites, through a relay
|
|
365
|
+
* const client = createMeroClient({
|
|
366
|
+
* transport: 'relay',
|
|
367
|
+
* relay: connection.relay,
|
|
368
|
+
* // the relay node's signing key, learned out of band — without it the
|
|
369
|
+
* // client writes but does not observe
|
|
370
|
+
* observe: { nodeKey },
|
|
371
|
+
* });
|
|
372
|
+
*
|
|
373
|
+
* await client.rpc.execute({ contextId, method: 'set', argsJson: { key, value } });
|
|
374
|
+
* if (client.canSubscribe) client.events.on('event', onEvent);
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
export function createMeroClient(config) {
|
|
378
|
+
return new MeroClient(config);
|
|
379
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading from the relay — because a relay is a node.
|
|
3
|
+
*
|
|
4
|
+
* # What was wrong with "a relay-transport client cannot observe"
|
|
5
|
+
*
|
|
6
|
+
* The first version of this transport reported `canSubscribe === false` for
|
|
7
|
+
* every relay, on the reasoning that a relay serves only `GET`/`POST
|
|
8
|
+
* /admin-api/contexts/:id/intents`. That is true of the *unauthenticated*
|
|
9
|
+
* surface, and it is the wrong conclusion: a relay is an ordinary node that
|
|
10
|
+
* happens to also run intents for other people. The same origin serves
|
|
11
|
+
* `/auth/challenge`, `/auth/token`, `/sse` and `/ws`, and a keyholder holding
|
|
12
|
+
* an `AccountProof<DeviceCert>` can log in to it with {@link login} — the
|
|
13
|
+
* `account_proof` provider grants exactly `context:intent`, `context:query`,
|
|
14
|
+
* `context:subscribe`. Subscribe is in the provider's own default grant, not
|
|
15
|
+
* something extra that has to be asked for, and a delegated write on such a
|
|
16
|
+
* session was observed arriving as a `StateMutation` frame whose `newRoot`
|
|
17
|
+
* matched the write's `rootHash`.
|
|
18
|
+
*
|
|
19
|
+
* So the gap was never "a relay cannot be observed". It is exactly one missing
|
|
20
|
+
* input.
|
|
21
|
+
*
|
|
22
|
+
* # The one missing input, and why it is not filled in from somewhere
|
|
23
|
+
*
|
|
24
|
+
* {@link login} requires the node's device signing key as a *separately
|
|
25
|
+
* supplied* parameter. It is not read out of the challenge response on purpose:
|
|
26
|
+
* a value learned from the party you are about to authenticate to lets whoever
|
|
27
|
+
* answered choose what your device signs a statement about. That is the whole
|
|
28
|
+
* attack the parameter exists to stop.
|
|
29
|
+
*
|
|
30
|
+
* For a self-hosted relay the operator can hand the key over out of band, and
|
|
31
|
+
* this module works today. For a cloud-hosted relay, `CloudRelay` carries
|
|
32
|
+
* `peerId`, `relayUrl`, `executorAccount`, `status` and `authorshipReady` —
|
|
33
|
+
* and no node key. `peerId` is a libp2p identity and is **not** a substitute:
|
|
34
|
+
* signing a statement about a libp2p peer id would be signing about a different
|
|
35
|
+
* key than the one the node authenticates with. That is filed upstream as mdma
|
|
36
|
+
* #312; until it lands, the hosted case has no key and reports
|
|
37
|
+
* `canSubscribe === false` rather than pretending.
|
|
38
|
+
*
|
|
39
|
+
* Nothing here invents a key, and nothing here falls back to "some other node".
|
|
40
|
+
*/
|
|
41
|
+
import { SseClient } from '../events/sse.js';
|
|
42
|
+
import { WsClient } from '../events/ws.js';
|
|
43
|
+
import { type Audience } from '../login/index.js';
|
|
44
|
+
import type { Signer } from '../signer/signer.js';
|
|
45
|
+
/**
|
|
46
|
+
* How a relay-transport client observes the relay node.
|
|
47
|
+
*
|
|
48
|
+
* Every field but {@link RelayObserveConfig.nodeKey} has a correct default
|
|
49
|
+
* taken from the `RelayClient` already configured — the author's proof, its
|
|
50
|
+
* signer and the relay's own URL are the same values a login needs, and asking
|
|
51
|
+
* for a second copy is how two copies come to disagree.
|
|
52
|
+
*/
|
|
53
|
+
export interface RelayObserveConfig {
|
|
54
|
+
/**
|
|
55
|
+
* The relay node's ed25519 device signing key, hex (32 bytes), **as this
|
|
56
|
+
* client pinned it**.
|
|
57
|
+
*
|
|
58
|
+
* Learn it out of band: from the operator of a self-hosted relay, or from a
|
|
59
|
+
* certificate you already trust. Never from the node itself — see the module
|
|
60
|
+
* note.
|
|
61
|
+
*
|
|
62
|
+
* `null` / `undefined` is a first-class answer meaning "nobody told me", and
|
|
63
|
+
* it is what the hosted path passes today (mdma #312). It makes
|
|
64
|
+
* `canSubscribe` false; it never makes the client guess.
|
|
65
|
+
*/
|
|
66
|
+
nodeKey?: string | null;
|
|
67
|
+
/**
|
|
68
|
+
* The client surface the session is bound to.
|
|
69
|
+
*
|
|
70
|
+
* Defaults to this runtime's honest self-description: the browser's own
|
|
71
|
+
* `location.origin`, or `{ kind: 'cli' }` where there is no browser. Passing
|
|
72
|
+
* something else is for a client that knows better — a signed native app
|
|
73
|
+
* naming its code-signing identity, say. A wrong audience fails the login
|
|
74
|
+
* loudly rather than quietly widening anything.
|
|
75
|
+
*/
|
|
76
|
+
audience?: Audience;
|
|
77
|
+
/** The node to log in to. Defaults to the relay's own URL — they are the same node. */
|
|
78
|
+
nodeUrl?: string;
|
|
79
|
+
/** The author's `AccountProof<DeviceCert>`, hex. Defaults to the relay client's. */
|
|
80
|
+
accountProof?: string;
|
|
81
|
+
/** The device signer. Defaults to the relay client's — the same device authors and observes. */
|
|
82
|
+
signer?: Signer;
|
|
83
|
+
/** How this client names itself to the node. Defaults to the audience's own spelling. */
|
|
84
|
+
clientName?: string;
|
|
85
|
+
/** Seconds the login statement stays valid. Defaults to `login`'s own default. */
|
|
86
|
+
ttlSeconds?: number;
|
|
87
|
+
/** Injected for tests and non-browser runtimes. */
|
|
88
|
+
fetch?: typeof fetch;
|
|
89
|
+
timeoutMs?: number;
|
|
90
|
+
}
|
|
91
|
+
/** What {@link RelayObserver} needs once the defaults have been resolved. */
|
|
92
|
+
export interface ResolvedObserveConfig {
|
|
93
|
+
nodeUrl: string;
|
|
94
|
+
nodeKey: string;
|
|
95
|
+
accountProof: string;
|
|
96
|
+
audience: Audience;
|
|
97
|
+
signer: () => Promise<Signer>;
|
|
98
|
+
clientName?: string;
|
|
99
|
+
ttlSeconds?: number;
|
|
100
|
+
fetch?: typeof fetch;
|
|
101
|
+
timeoutMs?: number;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* This runtime's honest self-description, used when no audience is given.
|
|
105
|
+
*
|
|
106
|
+
* The origin is taken verbatim from the browser rather than normalized: the
|
|
107
|
+
* node compares it byte for byte, and two spellings of one origin would mean a
|
|
108
|
+
* client that disagrees with itself about which surface a token was for.
|
|
109
|
+
*/
|
|
110
|
+
export declare function defaultAudience(): Audience;
|
|
111
|
+
/**
|
|
112
|
+
* A node session over the relay, and the event clients it feeds.
|
|
113
|
+
*
|
|
114
|
+
* The session is minted lazily, on the first thing that actually needs a token.
|
|
115
|
+
* That is not an optimization: `client.events` is a *getter*, so constructing a
|
|
116
|
+
* client would otherwise have to perform two HTTP calls and a signature before
|
|
117
|
+
* anyone had asked to observe anything.
|
|
118
|
+
*/
|
|
119
|
+
export declare class RelayObserver {
|
|
120
|
+
private readonly config;
|
|
121
|
+
private session;
|
|
122
|
+
private sessionExpiresAt;
|
|
123
|
+
/** In-flight login, so two surfaces asking at once mint one session, not two. */
|
|
124
|
+
private pending;
|
|
125
|
+
private sseClient;
|
|
126
|
+
private wsClient;
|
|
127
|
+
constructor(config: ResolvedObserveConfig);
|
|
128
|
+
/** The node key this observer logs in with. Read for diagnostics. */
|
|
129
|
+
get nodeKey(): string;
|
|
130
|
+
/**
|
|
131
|
+
* A usable access token, logging in if there is not one.
|
|
132
|
+
*
|
|
133
|
+
* Re-mints once the current token is within {@link EXPIRY_SKEW_MS} of
|
|
134
|
+
* expiring, and on every call when the token's lifetime cannot be read —
|
|
135
|
+
* reconnects are backed off, so an extra handshake costs far less than a
|
|
136
|
+
* stream that silently stops. The refresh token is *held* but not spent:
|
|
137
|
+
* rotating it is tracked in #87/#88 and a second login is correct in the
|
|
138
|
+
* meantime, where re-presenting an unrotated refresh token would not be.
|
|
139
|
+
*/
|
|
140
|
+
accessToken(): Promise<string>;
|
|
141
|
+
private loginNow;
|
|
142
|
+
/**
|
|
143
|
+
* SSE events over the relay node — the same `SseClient` a node client hands
|
|
144
|
+
* out, constructed the same way, so nothing downstream can tell which
|
|
145
|
+
* transport produced it.
|
|
146
|
+
*/
|
|
147
|
+
get events(): SseClient;
|
|
148
|
+
/** WebSocket events over the relay node. @experimental — prefer `events`. */
|
|
149
|
+
get ws(): WsClient;
|
|
150
|
+
/** Close whatever was opened. Never touches the relay's write path. */
|
|
151
|
+
close(): void;
|
|
152
|
+
}
|