@calimero-network/mero-js 26.11.0 → 27.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 +73 -288
- package/dist/cloud-v2/attestation.d.ts +91 -0
- package/dist/cloud-v2/attestation.js +135 -0
- package/dist/cloud-v2/capabilities.d.ts +30 -0
- package/dist/cloud-v2/capabilities.js +31 -0
- package/dist/cloud-v2/client.d.ts +426 -0
- package/dist/cloud-v2/client.js +646 -0
- package/dist/cloud-v2/errors.d.ts +98 -0
- package/dist/cloud-v2/errors.js +165 -0
- package/dist/cloud-v2/index.d.ts +10 -0
- package/dist/cloud-v2/index.js +5 -0
- package/dist/cloud-v2/passkey.d.ts +27 -0
- package/dist/cloud-v2/passkey.js +73 -0
- package/dist/crypto/internal.d.ts +3 -37
- package/dist/crypto/internal.js +3 -72
- package/dist/index.browser.mjs +4 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +1750 -12772
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +3 -56
- package/dist/index.js +14 -75
- package/dist/index.mjs +1750 -12772
- package/dist/index.mjs.map +4 -4
- package/dist/sealed/index.d.ts +4 -4
- package/dist/sealed/index.js +2 -2
- package/dist/sealed/release.d.ts +21 -76
- package/dist/sealed/release.js +24 -108
- package/dist/sealed/sealed.d.ts +8 -56
- package/dist/sealed/sealed.js +11 -89
- package/dist/sealed/verify.d.ts +7 -13
- package/dist/sealed/verify.js +3 -7
- package/package.json +8 -13
- package/dist/account/account-admin.d.ts +0 -80
- package/dist/account/account-admin.js +0 -346
- package/dist/account/account.d.ts +0 -196
- package/dist/account/account.js +0 -269
- package/dist/account/application-id.d.ts +0 -50
- package/dist/account/application-id.js +0 -83
- package/dist/account/bootstrap-from-invitation.d.ts +0 -110
- package/dist/account/bootstrap-from-invitation.js +0 -80
- package/dist/account/create-context.d.ts +0 -103
- package/dist/account/create-context.js +0 -267
- package/dist/account/govern.d.ts +0 -24
- package/dist/account/govern.js +0 -50
- package/dist/account/index.d.ts +0 -26
- package/dist/account/index.js +0 -15
- package/dist/account/join-as-account.d.ts +0 -23
- package/dist/account/join-as-account.js +0 -34
- package/dist/account/join-with-node.d.ts +0 -115
- package/dist/account/join-with-node.js +0 -161
- package/dist/account/node-admin.d.ts +0 -20
- package/dist/account/node-admin.js +0 -33
- package/dist/account/relay-from-invitation.d.ts +0 -137
- package/dist/account/relay-from-invitation.js +0 -180
- package/dist/account/relay-login-error.d.ts +0 -32
- package/dist/account/relay-login-error.js +0 -34
- package/dist/account/session.d.ts +0 -266
- package/dist/account/session.js +0 -915
- package/dist/account/wordlist.d.ts +0 -16
- package/dist/account/wordlist.js +0 -273
- package/dist/admin-api/admin-client.d.ts +0 -688
- package/dist/admin-api/admin-client.js +0 -1230
- package/dist/admin-api/admin-factory.d.ts +0 -14
- package/dist/admin-api/admin-factory.js +0 -22
- package/dist/admin-api/admin-types.d.ts +0 -2046
- package/dist/admin-api/admin-types.js +0 -12
- package/dist/admin-api/index.d.ts +0 -4
- package/dist/admin-api/index.js +0 -5
- package/dist/admin-api/redeem-invitation.d.ts +0 -101
- package/dist/admin-api/redeem-invitation.js +0 -200
- package/dist/auth/index.d.ts +0 -34
- package/dist/auth/index.js +0 -67
- package/dist/auth-api/auth-client.d.ts +0 -31
- package/dist/auth-api/auth-client.js +0 -117
- package/dist/auth-api/auth-factory.d.ts +0 -7
- package/dist/auth-api/auth-factory.js +0 -41
- package/dist/auth-api/auth-types.d.ts +0 -130
- package/dist/auth-api/auth-types.js +0 -2
- package/dist/auth-api/index.d.ts +0 -3
- package/dist/auth-api/index.js +0 -4
- package/dist/capabilities.d.ts +0 -84
- package/dist/capabilities.js +0 -117
- package/dist/cloud/account-ownership.d.ts +0 -63
- package/dist/cloud/account-ownership.js +0 -87
- package/dist/cloud/cloud-client.d.ts +0 -949
- package/dist/cloud/cloud-client.js +0 -1068
- package/dist/cloud/connect.d.ts +0 -213
- package/dist/cloud/connect.js +0 -260
- package/dist/cloud/enrol-redirect.d.ts +0 -181
- package/dist/cloud/enrol-redirect.js +0 -244
- package/dist/cloud/index.d.ts +0 -12
- package/dist/cloud/index.js +0 -10
- package/dist/cloud/link-redirect.d.ts +0 -95
- package/dist/cloud/link-redirect.js +0 -121
- package/dist/cloud/link-state.d.ts +0 -36
- package/dist/cloud/link-state.js +0 -104
- package/dist/cloud/routing-proof.d.ts +0 -113
- package/dist/cloud/routing-proof.js +0 -96
- package/dist/device-cert/device-cert.d.ts +0 -189
- package/dist/device-cert/device-cert.js +0 -239
- package/dist/device-cert/device-scope.d.ts +0 -58
- package/dist/device-cert/device-scope.js +0 -76
- package/dist/device-cert/index.d.ts +0 -4
- package/dist/device-cert/index.js +0 -3
- package/dist/ephemeral/codec.d.ts +0 -4
- package/dist/ephemeral/codec.js +0 -12
- package/dist/ephemeral/index.d.ts +0 -57
- package/dist/ephemeral/index.js +0 -101
- package/dist/ephemeral/types.d.ts +0 -27
- package/dist/ephemeral/types.js +0 -1
- package/dist/errors/classify.d.ts +0 -65
- package/dist/errors/classify.js +0 -117
- package/dist/errors/index.d.ts +0 -2
- package/dist/errors/index.js +0 -1
- package/dist/events/group.d.ts +0 -95
- package/dist/events/group.js +0 -10
- package/dist/events/index.d.ts +0 -5
- package/dist/events/index.js +0 -2
- package/dist/events/sse.d.ts +0 -127
- package/dist/events/sse.js +0 -469
- package/dist/events/ws.d.ts +0 -57
- package/dist/events/ws.js +0 -212
- package/dist/http-client/api-response.d.ts +0 -15
- package/dist/http-client/api-response.js +0 -1
- package/dist/http-client/http-factory.d.ts +0 -71
- package/dist/http-client/http-factory.js +0 -68
- package/dist/http-client/http-types.d.ts +0 -87
- package/dist/http-client/http-types.js +0 -1
- package/dist/http-client/index.d.ts +0 -7
- package/dist/http-client/index.js +0 -11
- package/dist/http-client/path-segment.d.ts +0 -1
- package/dist/http-client/path-segment.js +0 -8
- package/dist/http-client/retry.d.ts +0 -5
- package/dist/http-client/retry.js +0 -82
- package/dist/http-client/signal-utils.d.ts +0 -2
- package/dist/http-client/signal-utils.js +0 -41
- package/dist/http-client/web-client.d.ts +0 -80
- package/dist/http-client/web-client.js +0 -557
- package/dist/invitation/index.d.ts +0 -2
- package/dist/invitation/index.js +0 -2
- package/dist/invitation/invitation.d.ts +0 -106
- package/dist/invitation/invitation.js +0 -104
- package/dist/login/index.d.ts +0 -4
- package/dist/login/index.js +0 -3
- package/dist/login/login.d.ts +0 -111
- package/dist/login/login.js +0 -110
- package/dist/login/session.d.ts +0 -124
- package/dist/login/session.js +0 -141
- package/dist/mero-js.d.ts +0 -231
- package/dist/mero-js.js +0 -469
- package/dist/namespace-op/index.d.ts +0 -2
- package/dist/namespace-op/index.js +0 -3
- package/dist/namespace-op/namespace-op.d.ts +0 -150
- package/dist/namespace-op/namespace-op.js +0 -191
- package/dist/nodeDiscovery.d.ts +0 -47
- package/dist/nodeDiscovery.js +0 -101
- package/dist/owner-op/index.d.ts +0 -2
- package/dist/owner-op/index.js +0 -3
- package/dist/owner-op/owner-op.d.ts +0 -119
- package/dist/owner-op/owner-op.js +0 -214
- package/dist/presence/relay-presence.d.ts +0 -56
- package/dist/presence/relay-presence.js +0 -107
- package/dist/presence/statement.d.ts +0 -12
- package/dist/presence/statement.js +0 -17
- package/dist/relay/index.d.ts +0 -4
- package/dist/relay/index.js +0 -2
- package/dist/relay/nonce-source.d.ts +0 -72
- package/dist/relay/nonce-source.js +0 -111
- package/dist/relay/relay-client.d.ts +0 -472
- package/dist/relay/relay-client.js +0 -518
- package/dist/relay-attestation/attest-node-key.d.ts +0 -66
- package/dist/relay-attestation/attest-node-key.js +0 -110
- package/dist/relay-attestation/index.d.ts +0 -1
- package/dist/relay-attestation/index.js +0 -1
- package/dist/request/caller-proof.d.ts +0 -43
- package/dist/request/caller-proof.js +0 -36
- package/dist/request/index.d.ts +0 -3
- package/dist/request/index.js +0 -3
- package/dist/request/proof-signer.d.ts +0 -49
- package/dist/request/proof-signer.js +0 -59
- package/dist/request/request-sig.d.ts +0 -99
- package/dist/request/request-sig.js +0 -95
- package/dist/request-proof/authorizer.d.ts +0 -41
- package/dist/request-proof/authorizer.js +0 -60
- package/dist/request-proof/index.d.ts +0 -4
- package/dist/request-proof/index.js +0 -2
- package/dist/request-proof/request-proof.d.ts +0 -53
- package/dist/request-proof/request-proof.js +0 -95
- package/dist/rpc/index.d.ts +0 -64
- package/dist/rpc/index.js +0 -78
- package/dist/signer/index.d.ts +0 -2
- package/dist/signer/index.js +0 -3
- package/dist/signer/signer.d.ts +0 -92
- package/dist/signer/signer.js +0 -147
- package/dist/token-store/index.d.ts +0 -27
- package/dist/token-store/index.js +0 -62
- package/dist/transport/index.d.ts +0 -6
- package/dist/transport/index.js +0 -3
- package/dist/transport/mero-client.d.ts +0 -294
- package/dist/transport/mero-client.js +0 -386
- package/dist/transport/relay-observer.d.ts +0 -152
- package/dist/transport/relay-observer.js +0 -173
- package/dist/transport/relay-transport.d.ts +0 -73
- package/dist/transport/relay-transport.js +0 -94
- package/dist/transport/types.d.ts +0 -100
- package/dist/transport/types.js +0 -32
- package/dist/types/api-response.d.ts +0 -15
- package/dist/types/api-response.js +0 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.js +0 -1
- package/dist/warrant/creation-warrant.d.ts +0 -163
- package/dist/warrant/creation-warrant.js +0 -194
- package/dist/warrant/governance-op.d.ts +0 -202
- package/dist/warrant/governance-op.js +0 -355
- package/dist/warrant/governance-warrant.d.ts +0 -124
- package/dist/warrant/governance-warrant.js +0 -164
- package/dist/warrant/index.d.ts +0 -8
- package/dist/warrant/index.js +0 -5
- package/dist/warrant/warrant.d.ts +0 -188
- package/dist/warrant/warrant.js +0 -202
|
@@ -1,1068 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Calimero Cloud (MDMA) client.
|
|
3
|
-
*
|
|
4
|
-
* # Why the SDK knows about the cloud at all
|
|
5
|
-
*
|
|
6
|
-
* Delegated execution lets an account that holds one signing key write into a
|
|
7
|
-
* context without running a node — but only through a *relay*, and a relay is
|
|
8
|
-
* something the client has to be told about. A warrant names an executor
|
|
9
|
-
* account, so a client that cannot discover which relay serves its namespace,
|
|
10
|
-
* and which account that relay writes as, cannot mint a usable warrant at all.
|
|
11
|
-
*
|
|
12
|
-
* For a self-hosted namespace the operator hands those two facts over out of
|
|
13
|
-
* band. For the hosted tier the cloud is the directory: the user signs in, the
|
|
14
|
-
* cloud knows which namespaces they own and which attested TEE nodes are
|
|
15
|
-
* assigned to each, and it answers with the relay's URL and executor account.
|
|
16
|
-
* That is what this client is for — it is the discovery half of
|
|
17
|
-
* {@link RelayClient}, not a second way to talk to a node.
|
|
18
|
-
*
|
|
19
|
-
* # What it deliberately is not
|
|
20
|
-
*
|
|
21
|
-
* It never sees a warrant, a device secret, or an intent. Discovery goes
|
|
22
|
-
* through the cloud; the write goes straight from the client to the relay. A
|
|
23
|
-
* cloud that proxied intents would read every method and argument its users
|
|
24
|
-
* ever sent, and would be a throughput bottleneck on a path that has no reason
|
|
25
|
-
* to involve it.
|
|
26
|
-
*/
|
|
27
|
-
import { signAccountHaClaim } from './account-ownership.js';
|
|
28
|
-
import { resolveRoot, resolveRootPair, signAccountLink, signAccountLogin, } from '../account/index.js';
|
|
29
|
-
import { HTTPError } from '../http-client/web-client.js';
|
|
30
|
-
import { pathSegment } from '../http-client/path-segment.js';
|
|
31
|
-
import { ACCOUNT_HEX, VERIFIER_HEX, beginLinkState, linkChallenge, linkGrantClaims, takeLinkVerifier, } from './link-state.js';
|
|
32
|
-
import { routingProofHeaders, } from './routing-proof.js';
|
|
33
|
-
/**
|
|
34
|
-
* The hosted cloud's **API** when a caller names no other.
|
|
35
|
-
*
|
|
36
|
-
* `manager.cloud.calimero.network`, not `cloud.calimero.network`: the latter
|
|
37
|
-
* serves the React app, which answers an API call with 405 and an HTML body.
|
|
38
|
-
* Every method on this client is an API call, so the bare `new CloudClient()`
|
|
39
|
-
* used to fail at the first request with a parse error rather than at
|
|
40
|
-
* construction — and the URL it was talking to looked right in the message.
|
|
41
|
-
*
|
|
42
|
-
* The web origin still exists in this package: the consent screen in
|
|
43
|
-
* `link-redirect.ts` is a page a person visits, and that one is the app.
|
|
44
|
-
*/
|
|
45
|
-
const DEFAULT_CLOUD_BASE_URL = 'https://manager.cloud.calimero.network';
|
|
46
|
-
/**
|
|
47
|
-
* The cloud's own `can_execute` where it sends one, else `legacy`.
|
|
48
|
-
*
|
|
49
|
-
* The cloud decides relaying by role (`RelayTee` relays, `ReadOnlyTee` never
|
|
50
|
-
* does) and only falls back to the `CAN_AUTHOR_ON_BEHALF` grant for a node
|
|
51
|
-
* that reports none, so re-deriving it here from `authorship_ready` would call
|
|
52
|
-
* a grant-holding `ReadOnlyTee` writable. `legacy` is the grant-based answer,
|
|
53
|
-
* for a cloud older than the field — the only rule such a cloud knew.
|
|
54
|
-
*/
|
|
55
|
-
function canExecuteOf(row, legacy) {
|
|
56
|
-
return typeof row.can_execute === 'boolean' ? row.can_execute : legacy;
|
|
57
|
-
}
|
|
58
|
-
/** The reported `tee_role`, or `null` when absent (older cloud or sidecar). */
|
|
59
|
-
function teeRoleOf(row) {
|
|
60
|
-
return typeof row.tee_role === 'string' && row.tee_role !== '' ? row.tee_role : null;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* The first relay a warrant can be spent at, or `null`.
|
|
64
|
-
*
|
|
65
|
-
* Shared by {@link CloudClient.findExecutingRelay} and `connectCloud`, which
|
|
66
|
-
* already holds the list and must not fetch it twice.
|
|
67
|
-
*/
|
|
68
|
-
export function pickExecutingRelay(relays) {
|
|
69
|
-
return (relays.find((r) => r.canExecute && r.relayUrl !== null && r.executorAccount !== null) ?? null);
|
|
70
|
-
}
|
|
71
|
-
/** `join` from a relay row, or `null` when absent or not an object. */
|
|
72
|
-
function joinOf(row) {
|
|
73
|
-
const raw = row.join;
|
|
74
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
75
|
-
return null;
|
|
76
|
-
const join = raw;
|
|
77
|
-
if (typeof join.state !== 'string')
|
|
78
|
-
return null;
|
|
79
|
-
const refusals = Array.isArray(join.refusals)
|
|
80
|
-
? join.refusals
|
|
81
|
-
.filter((r) => !!r && typeof r === 'object')
|
|
82
|
-
.map((r) => ({ peer: String(r.peer ?? ''), reason: String(r.reason ?? '') }))
|
|
83
|
-
: [];
|
|
84
|
-
return {
|
|
85
|
-
state: join.state,
|
|
86
|
-
attempts: typeof join.attempts === 'number' ? join.attempts : 0,
|
|
87
|
-
refusals,
|
|
88
|
-
error: typeof join.error === 'string' ? join.error : null,
|
|
89
|
-
reportedAt: typeof join.reported_at === 'string' ? join.reported_at : null,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
function accountHexOrNull(value) {
|
|
93
|
-
return typeof value === 'string' && ACCOUNT_HEX.test(value) ? value : null;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* The cloud's refusals of {@link CloudClient.enableHaAsAccount} that a
|
|
97
|
-
* person can do something about. Both are `409`s with `{"error": code}`.
|
|
98
|
-
*
|
|
99
|
-
* Extends {@link HTTPError}, so `instanceof HTTPError` handling still catches it.
|
|
100
|
-
*/
|
|
101
|
-
export class AccountHaRefusedError extends HTTPError {
|
|
102
|
-
constructor(
|
|
103
|
-
/** The cloud's `error` code, verbatim. */
|
|
104
|
-
code, status, statusText, url, headers, bodyText) {
|
|
105
|
-
super(status, statusText, url, headers, bodyText);
|
|
106
|
-
this.code = code;
|
|
107
|
-
this.name = 'AccountHaRefusedError';
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* `account_not_linked`: the account was never linked to a cloud user through
|
|
112
|
-
* the wallet, so the cloud has nobody to host the namespace for. Linking it
|
|
113
|
-
* fixes this; retrying does not.
|
|
114
|
-
*/
|
|
115
|
-
export class AccountNotLinkedError extends AccountHaRefusedError {
|
|
116
|
-
constructor() {
|
|
117
|
-
super(...arguments);
|
|
118
|
-
this.name = 'AccountNotLinkedError';
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
AccountNotLinkedError.CODE = 'account_not_linked';
|
|
122
|
-
/**
|
|
123
|
-
* `account_linked_to_several_users`: more than one cloud user linked this
|
|
124
|
-
* account, so the cloud cannot tell whose quota hosts the namespace. Unlink it
|
|
125
|
-
* from all but one.
|
|
126
|
-
*/
|
|
127
|
-
export class AccountLinkedToSeveralUsersError extends AccountHaRefusedError {
|
|
128
|
-
constructor() {
|
|
129
|
-
super(...arguments);
|
|
130
|
-
this.name = 'AccountLinkedToSeveralUsersError';
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
AccountLinkedToSeveralUsersError.CODE = 'account_linked_to_several_users';
|
|
134
|
-
/**
|
|
135
|
-
* `ha_request_pending` (409): another namespace of this account is waiting for
|
|
136
|
-
* its first fleet node to be admitted. The route allows one at a time; it
|
|
137
|
-
* clears once that node is admitted, or by disabling HA on it from a cloud
|
|
138
|
-
* session. The claim was not spent.
|
|
139
|
-
*/
|
|
140
|
-
export class HaRequestPendingError extends AccountHaRefusedError {
|
|
141
|
-
constructor() {
|
|
142
|
-
super(...arguments);
|
|
143
|
-
this.name = 'HaRequestPendingError';
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
HaRequestPendingError.CODE = 'ha_request_pending';
|
|
147
|
-
/**
|
|
148
|
-
* `unknown_relay` (422): the cloud runs no relay at `relayUrl`. The claim was
|
|
149
|
-
* not spent.
|
|
150
|
-
*/
|
|
151
|
-
export class UnknownRelayError extends AccountHaRefusedError {
|
|
152
|
-
constructor() {
|
|
153
|
-
super(...arguments);
|
|
154
|
-
this.name = 'UnknownRelayError';
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
UnknownRelayError.CODE = 'unknown_relay';
|
|
158
|
-
/**
|
|
159
|
-
* `relay_not_dialable` (422): the relay is the cloud's but has not reported
|
|
160
|
-
* its addresses yet. The claim was not spent; retrying later can succeed.
|
|
161
|
-
*/
|
|
162
|
-
export class RelayNotDialableError extends AccountHaRefusedError {
|
|
163
|
-
constructor() {
|
|
164
|
-
super(...arguments);
|
|
165
|
-
this.name = 'RelayNotDialableError';
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
RelayNotDialableError.CODE = 'relay_not_dialable';
|
|
169
|
-
const ACCOUNT_HA_REFUSALS = {
|
|
170
|
-
409: [AccountNotLinkedError, AccountLinkedToSeveralUsersError, HaRequestPendingError],
|
|
171
|
-
422: [UnknownRelayError, RelayNotDialableError],
|
|
172
|
-
};
|
|
173
|
-
/**
|
|
174
|
-
* The `error` code of a 409 body. Accepts `{"error": code}` (the contract) and
|
|
175
|
-
* FastAPI's `{"detail": code}` / `{"detail": {"error": code}}`, so how the
|
|
176
|
-
* route raises it does not change what a caller catches.
|
|
177
|
-
*/
|
|
178
|
-
function conflictCode(bodyText) {
|
|
179
|
-
if (!bodyText)
|
|
180
|
-
return undefined;
|
|
181
|
-
try {
|
|
182
|
-
const body = JSON.parse(bodyText);
|
|
183
|
-
if (typeof body?.error === 'string')
|
|
184
|
-
return body.error;
|
|
185
|
-
if (typeof body?.detail === 'string')
|
|
186
|
-
return body.detail;
|
|
187
|
-
const nested = body?.detail?.error;
|
|
188
|
-
if (typeof nested === 'string')
|
|
189
|
-
return nested;
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
// Not JSON: not one of ours.
|
|
193
|
-
}
|
|
194
|
-
return undefined;
|
|
195
|
-
}
|
|
196
|
-
/** Response headers MDMA uses to hand back a rolling-refreshed session. */
|
|
197
|
-
const REFRESH_HEADER = 'x-mdma-session-refresh';
|
|
198
|
-
const REFRESH_EXPIRES_HEADER = 'x-mdma-session-expires';
|
|
199
|
-
export class CloudClient {
|
|
200
|
-
constructor(config = {}) {
|
|
201
|
-
this.session = null;
|
|
202
|
-
this.baseUrl = (config.cloudBaseUrl || DEFAULT_CLOUD_BASE_URL).replace(/\/+$/, '');
|
|
203
|
-
this.onSession = config.onSession;
|
|
204
|
-
// Bound through an arrow function: an unbound `globalThis.fetch` throws
|
|
205
|
-
// "Illegal invocation" in a browser, the same reason the node HTTP clients
|
|
206
|
-
// wrap it.
|
|
207
|
-
const injected = config.fetch;
|
|
208
|
-
this.fetchImpl = injected
|
|
209
|
-
? (input, init) => injected(input, init)
|
|
210
|
-
: (input, init) => globalThis.fetch(input, init);
|
|
211
|
-
this.timeoutMs = config.timeoutMs ?? 10000;
|
|
212
|
-
this.routingCredential = config.routingCredential;
|
|
213
|
-
if (config.sessionToken) {
|
|
214
|
-
// Expiry unknown until the server tells us; 0 means "do not reason about
|
|
215
|
-
// it locally", never "expired" — the server is the only judge and it
|
|
216
|
-
// answers 401 when it disagrees.
|
|
217
|
-
this.session = {
|
|
218
|
-
sessionToken: config.sessionToken,
|
|
219
|
-
expiresAt: 0,
|
|
220
|
-
user: { email: '' },
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
/** The current session, or `null` when nobody is signed in. */
|
|
225
|
-
getSession() {
|
|
226
|
-
return this.session;
|
|
227
|
-
}
|
|
228
|
-
/** Whether a session token is held. Says nothing about the server agreeing. */
|
|
229
|
-
isSignedIn() {
|
|
230
|
-
return this.session !== null;
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Exchange a Google OAuth2 ID token for an MDMA session.
|
|
234
|
-
*
|
|
235
|
-
* The app owns the Google flow (One Tap, an auth-code popup, a native
|
|
236
|
-
* browser) and hands the resulting ID token here once. Everything after this
|
|
237
|
-
* uses the returned session token, so the Google credential does not have to
|
|
238
|
-
* be kept or re-minted per call.
|
|
239
|
-
*/
|
|
240
|
-
async signInWithGoogle(idToken) {
|
|
241
|
-
const body = await this.request('POST', '/api/auth/google', { body: { id_token: idToken }, anonymous: true });
|
|
242
|
-
return this.adoptSession(body);
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Sign in as a Calimero account, proving it with its root key.
|
|
246
|
-
*
|
|
247
|
-
* The password-free counterpart to {@link signInWithGoogle}: no Google, no
|
|
248
|
-
* password, no server-held secret — the cloud mints a challenge, the account
|
|
249
|
-
* root signs it, and the session that comes back is the same MDMA session
|
|
250
|
-
* token every other cloud route already accepts.
|
|
251
|
-
*
|
|
252
|
-
* Doing this **once** is what makes an account's later device proofs mean
|
|
253
|
-
* something. A routing read shows that the caller holds a device the root
|
|
254
|
-
* certified, but certificates are public, so it can never show that anyone
|
|
255
|
-
* held the root; the cloud records this claim and from then on knows the
|
|
256
|
-
* account behind those reads was claimed by its owner. The claim is recorded
|
|
257
|
-
* even when the session is refused.
|
|
258
|
-
*
|
|
259
|
-
* @param root the account root: its 64-hex secret, or a `Signer` over a root
|
|
260
|
-
* this process may use and cannot read — the form a phrase-held root in a
|
|
261
|
-
* browser takes. Either way nothing but a signature over the cloud's nonce
|
|
262
|
-
* travels.
|
|
263
|
-
*
|
|
264
|
-
* A `403` means either the signature did not verify, or the account is not
|
|
265
|
-
* linked to a cloud login: ownership alone says who you are, and the link
|
|
266
|
-
* says what you are entitled to. Anyone can mint a root offline, so a session
|
|
267
|
-
* on the proof alone would authenticate perfectly and authorize nothing. The
|
|
268
|
-
* ownership claim is recorded either way, so linking later needs no re-proof.
|
|
269
|
-
*/
|
|
270
|
-
async signInWithAccount(root) {
|
|
271
|
-
const { signer, publicKey } = await resolveRoot(root);
|
|
272
|
-
const { nonce } = await this.getAccountLoginChallenge();
|
|
273
|
-
return this.submitAccountLogin({
|
|
274
|
-
rootPublicKey: publicKey,
|
|
275
|
-
nonce,
|
|
276
|
-
signature: await signAccountLogin({ signer, nonce }),
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Mint a challenge for an account root to sign.
|
|
281
|
-
*
|
|
282
|
-
* Public, and deliberately so: the caller has no session — obtaining one is
|
|
283
|
-
* what this exchange does. The nonce is not a capability, it is the thing a
|
|
284
|
-
* capability gets demonstrated against, and demonstrating it needs the root.
|
|
285
|
-
*
|
|
286
|
-
* Split out of {@link signInWithAccount} for the case that call cannot serve:
|
|
287
|
-
* a root held by a hardware key, a desktop app or an air-gapped machine,
|
|
288
|
-
* which signs without the secret ever reaching this process.
|
|
289
|
-
*/
|
|
290
|
-
async getAccountLoginChallenge() {
|
|
291
|
-
const body = await this.request('GET', '/api/auth/account/challenge', { anonymous: true });
|
|
292
|
-
return { nonce: body.nonce, expiresAtMs: body.expires_at_ms };
|
|
293
|
-
}
|
|
294
|
-
/** Post a proof assembled elsewhere. The other half of {@link getAccountLoginChallenge}. */
|
|
295
|
-
async submitAccountLogin(proof) {
|
|
296
|
-
const body = await this.request('POST', '/api/auth/account', {
|
|
297
|
-
body: {
|
|
298
|
-
root_public_key: proof.rootPublicKey,
|
|
299
|
-
nonce: proof.nonce,
|
|
300
|
-
signature: proof.signature,
|
|
301
|
-
},
|
|
302
|
-
anonymous: true,
|
|
303
|
-
});
|
|
304
|
-
return this.adoptSession(body);
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Refresh the session token explicitly.
|
|
308
|
-
*
|
|
309
|
-
* Rarely needed: MDMA rolling-refreshes on ordinary authenticated calls and
|
|
310
|
-
* this client adopts the replacement automatically. Use it when an app wants
|
|
311
|
-
* a fresh token before going idle rather than on the next request.
|
|
312
|
-
*/
|
|
313
|
-
async refreshSession() {
|
|
314
|
-
const body = await this.request('POST', '/api/auth/refresh');
|
|
315
|
-
return this.adoptSession(body);
|
|
316
|
-
}
|
|
317
|
-
/**
|
|
318
|
-
* Revoke this session server-side and forget it locally.
|
|
319
|
-
*
|
|
320
|
-
* Local state is cleared even if the server call fails: the caller asked to
|
|
321
|
-
* be signed out, and keeping a token they believe is gone is the worse of the
|
|
322
|
-
* two failures.
|
|
323
|
-
*/
|
|
324
|
-
async signOut() {
|
|
325
|
-
try {
|
|
326
|
-
if (this.session)
|
|
327
|
-
await this.request('POST', '/api/auth/logout', { parse: 'none' });
|
|
328
|
-
}
|
|
329
|
-
finally {
|
|
330
|
-
this.session = null;
|
|
331
|
-
void this.notifySession(null);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
/** Namespaces the signed-in user owns. */
|
|
335
|
-
async getMyNamespaces() {
|
|
336
|
-
const rows = await this.request('GET', '/api/cloud/me/namespaces');
|
|
337
|
-
return rows.map((row) => ({
|
|
338
|
-
namespaceId: String(row.namespace_id ?? row.group_id ?? ''),
|
|
339
|
-
groupId: String(row.group_id ?? row.namespace_id ?? ''),
|
|
340
|
-
haStatus: String(row.ha_status ?? 'none'),
|
|
341
|
-
haEnabledAt: row.ha_enabled_at ?? null,
|
|
342
|
-
contexts: Array.isArray(row.contexts) ? row.contexts : [],
|
|
343
|
-
fleetReplicas: row.fleet_replicas,
|
|
344
|
-
}));
|
|
345
|
-
}
|
|
346
|
-
/**
|
|
347
|
-
* The relays serving one namespace: where to present an intent, and as whom.
|
|
348
|
-
*
|
|
349
|
-
* Everything a client needs in order to mint a warrant it can actually
|
|
350
|
-
* spend. Filter on `canExecute` before signing — a warrant presented to a
|
|
351
|
-
* relay that does not relay (a `ReadOnlyTee` replica, or a node with no role
|
|
352
|
-
* and no grant) is refused *after* the author has burned a nonce from its
|
|
353
|
-
* monotonic sequence on it.
|
|
354
|
-
*/
|
|
355
|
-
async getNamespaceRelays(namespaceId) {
|
|
356
|
-
const body = await this.request('GET', `/api/cloud/me/namespaces/${pathSegment(namespaceId)}/relays`);
|
|
357
|
-
return (body.relays ?? []).map((row) => {
|
|
358
|
-
const relayUrl = row.relay_url ?? null;
|
|
359
|
-
const executorAccount = row.executor_account ?? null;
|
|
360
|
-
const authorshipReady = row.authorship_ready === true;
|
|
361
|
-
return {
|
|
362
|
-
peerId: String(row.peer_id ?? ''),
|
|
363
|
-
relayUrl,
|
|
364
|
-
executorAccount,
|
|
365
|
-
nodeKey: row.node_key ??
|
|
366
|
-
row.node_public_key ??
|
|
367
|
-
null,
|
|
368
|
-
status: String(row.status ?? ''),
|
|
369
|
-
authorshipReady,
|
|
370
|
-
teeRole: teeRoleOf(row),
|
|
371
|
-
canExecute: canExecuteOf(row, authorshipReady && relayUrl !== null && executorAccount !== null),
|
|
372
|
-
lastSeenAt: row.last_seen_at ?? null,
|
|
373
|
-
confirmedAt: row.confirmed_at ?? null,
|
|
374
|
-
admission: typeof row.admission === 'string' ? row.admission : null,
|
|
375
|
-
admissionHint: typeof row.admission_hint === 'string' ? row.admission_hint : null,
|
|
376
|
-
join: joinOf(row),
|
|
377
|
-
};
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* The first relay for `namespaceId` that can actually run an intent, or
|
|
382
|
-
* `null`.
|
|
383
|
-
*
|
|
384
|
-
* "Can actually run one" is several conditions, and a client that checks
|
|
385
|
-
* fewer gets a refusal it cannot explain: the cloud must know a URL, the
|
|
386
|
-
* relay must have reported the executor account a warrant has to name, and
|
|
387
|
-
* the node must relay — which the cloud folds into `canExecute`.
|
|
388
|
-
*/
|
|
389
|
-
async findExecutingRelay(namespaceId) {
|
|
390
|
-
const relays = await this.getNamespaceRelays(namespaceId);
|
|
391
|
-
return pickExecutingRelay(relays);
|
|
392
|
-
}
|
|
393
|
-
/**
|
|
394
|
-
* Where to reach a namespace without owning it: one read, both verbs.
|
|
395
|
-
*
|
|
396
|
-
* **Anonymous.** A joiner is by construction not the namespace owner and holds
|
|
397
|
-
* no cloud session — needing one is exactly what this path exists to avoid.
|
|
398
|
-
* Discovery is not authorization: merod refuses an `/admit` call unless the
|
|
399
|
-
* relaying node's account is on the invitation's signed `admitters` list, and
|
|
400
|
-
* refuses an intent unless the warrant is genuinely the member's, so naming a
|
|
401
|
-
* node grants the caller nothing it did not already have.
|
|
402
|
-
*
|
|
403
|
-
* A keyholder resolves a namespace to a node ONCE and then admits, reads and
|
|
404
|
-
* posts intents against that same node, which is why admission and writability
|
|
405
|
-
* are answered together rather than across two endpoints — the per-context
|
|
406
|
-
* read needs a context id a joiner does not yet have.
|
|
407
|
-
*
|
|
408
|
-
* An empty `nodes` list is a state, not an error: the cloud has no record of
|
|
409
|
-
* a node holding the namespace, so there is no hosted admitter.
|
|
410
|
-
*/
|
|
411
|
-
async getNamespaceRouting(namespaceId) {
|
|
412
|
-
const body = await this.request('GET', `/api/cloud/namespaces/${pathSegment(namespaceId)}/admitters`, {
|
|
413
|
-
anonymous: true,
|
|
414
|
-
headers: await this.routingProof(namespaceId),
|
|
415
|
-
});
|
|
416
|
-
return {
|
|
417
|
-
namespaceId: String(body.namespace_id ?? namespaceId),
|
|
418
|
-
nodes: (body.admitters ?? []).map((row) => {
|
|
419
|
-
const account = row.account ?? null;
|
|
420
|
-
const relayUrl = row.relay_url ?? null;
|
|
421
|
-
const fresh = row.fresh === true;
|
|
422
|
-
const authorshipReady = row.authorship_ready === true;
|
|
423
|
-
return {
|
|
424
|
-
peerId: String(row.peer_id ?? ''),
|
|
425
|
-
account,
|
|
426
|
-
relayUrl,
|
|
427
|
-
admitUrl: row.admit_url ?? null,
|
|
428
|
-
status: String(row.status ?? ''),
|
|
429
|
-
fresh,
|
|
430
|
-
canAdmit: row.can_admit === true,
|
|
431
|
-
authorshipReady,
|
|
432
|
-
teeRole: teeRoleOf(row),
|
|
433
|
-
canExecute: canExecuteOf(row, authorshipReady && relayUrl !== null && account !== null && fresh),
|
|
434
|
-
};
|
|
435
|
-
}),
|
|
436
|
-
servable: body.servable === true,
|
|
437
|
-
writable: body.writable === true,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Ask the cloud for a challenge to prove an account against.
|
|
442
|
-
*
|
|
443
|
-
* Public and unauthenticated on purpose, and safe to be: the nonce is sealed
|
|
444
|
-
* and bound to this one namespace, so it is not a capability — it is the
|
|
445
|
-
* thing a capability gets demonstrated against. A caller with no credential
|
|
446
|
-
* gains nothing by holding one.
|
|
447
|
-
*
|
|
448
|
-
* {@link getNamespaceRouting} calls this for you when the client was
|
|
449
|
-
* constructed with a `routingCredential`. Call it directly only to sign a
|
|
450
|
-
* challenge somewhere this client cannot reach the key.
|
|
451
|
-
*/
|
|
452
|
-
async getRoutingChallenge(namespaceId) {
|
|
453
|
-
const body = await this.request('GET', `/api/cloud/namespaces/${pathSegment(namespaceId)}/challenge`, {
|
|
454
|
-
anonymous: true,
|
|
455
|
-
});
|
|
456
|
-
return {
|
|
457
|
-
namespaceId: String(body.namespace_id ?? namespaceId),
|
|
458
|
-
nonce: String(body.nonce ?? ''),
|
|
459
|
-
expiresAtMs: Number(body.expires_at_ms ?? 0),
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* The proof headers for one routing read, or nothing when no credential was
|
|
464
|
-
* configured.
|
|
465
|
-
*
|
|
466
|
-
* A challenge per read rather than a cached one: they expire in about two
|
|
467
|
-
* minutes and are single-namespace, so caching would trade one cheap request
|
|
468
|
-
* for a class of expiry bug that only shows up on a slow client.
|
|
469
|
-
*/
|
|
470
|
-
async routingProof(namespaceId) {
|
|
471
|
-
if (!this.routingCredential)
|
|
472
|
-
return undefined;
|
|
473
|
-
const challenge = await this.getRoutingChallenge(namespaceId);
|
|
474
|
-
return routingProofHeaders(challenge, this.routingCredential);
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
|
-
* Ask the cloud for a challenge bound to one ACCOUNT.
|
|
478
|
-
*
|
|
479
|
-
* Public and unauthenticated like {@link getRoutingChallenge}: an account id
|
|
480
|
-
* is `H(genesis(root_pk))` and travels in the clear in every device-link op,
|
|
481
|
-
* so naming one here reveals nothing and authorises nothing. MDMA re-derives
|
|
482
|
-
* the account from the certificate and refuses a mismatch.
|
|
483
|
-
*/
|
|
484
|
-
async getAccountRelaysChallenge(accountId) {
|
|
485
|
-
const body = await this.request('GET', `/api/cloud/accounts/${pathSegment(accountId)}/challenge`, {
|
|
486
|
-
anonymous: true,
|
|
487
|
-
});
|
|
488
|
-
return {
|
|
489
|
-
accountId: String(body.account_id ?? accountId),
|
|
490
|
-
nonce: String(body.nonce ?? ''),
|
|
491
|
-
expiresAtMs: Number(body.expires_at_ms ?? 0),
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
/**
|
|
495
|
-
* Which relays serve this account — the bootstrap, and nothing more.
|
|
496
|
-
*
|
|
497
|
-
* A device holding a certificate can resolve a namespace it can *name*
|
|
498
|
-
* ({@link getNamespaceRouting}) and can ask a node it can *reach* what its
|
|
499
|
-
* account belongs to. It starts with neither. This closes that one gap.
|
|
500
|
-
*
|
|
501
|
-
* **It does not return namespaces, deliberately.** The node answers that from
|
|
502
|
-
* the governance DAG, scoped to the calling account: log in at one of these
|
|
503
|
-
* relays with an account proof and read its namespace listing. A cloud-side
|
|
504
|
-
* copy would be a second source of truth built from rows that linger for an
|
|
505
|
-
* account which left a namespace.
|
|
506
|
-
*
|
|
507
|
-
* So the sequence is: this → log in at a relay → ask it what you are in.
|
|
508
|
-
*
|
|
509
|
-
* Proven with the **device certificate**, not a cloud session, so it needs a
|
|
510
|
-
* `routingCredential` and throws without one rather than returning an empty
|
|
511
|
-
* list — "you configured no credential" and "you have no relays" are
|
|
512
|
-
* different answers and must not look alike.
|
|
513
|
-
*/
|
|
514
|
-
async getAccountRelays(accountId) {
|
|
515
|
-
if (!this.routingCredential) {
|
|
516
|
-
throw new Error('getAccountRelays needs a routingCredential: construct the client with the device ' +
|
|
517
|
-
'certificate and its signing secret, since this read is proven by the device, not by a session.');
|
|
518
|
-
}
|
|
519
|
-
const challenge = await this.getAccountRelaysChallenge(accountId);
|
|
520
|
-
const body = await this.request('GET', `/api/cloud/accounts/${pathSegment(accountId)}/relays`, {
|
|
521
|
-
anonymous: true,
|
|
522
|
-
headers: await routingProofHeaders(challenge, this.routingCredential),
|
|
523
|
-
});
|
|
524
|
-
return (body.relays ?? []).map((row) => ({
|
|
525
|
-
peerId: String(row.peer_id ?? ''),
|
|
526
|
-
relayUrl: row.relay_url ?? null,
|
|
527
|
-
fresh: row.fresh === true,
|
|
528
|
-
executorAccount: accountHexOrNull(row.executor_account),
|
|
529
|
-
assigned: row.assigned === true,
|
|
530
|
-
}));
|
|
531
|
-
}
|
|
532
|
-
/**
|
|
533
|
-
* The node to send a signed join to, chosen from those an invitation names.
|
|
534
|
-
*
|
|
535
|
-
* Pass the invitation's `admitters` and the intersection is done here, because
|
|
536
|
-
* skipping it is the failure this method exists to prevent: the cloud lists
|
|
537
|
-
* every node assigned to the namespace, while `admitters` is a snapshot signed
|
|
538
|
-
* when the invitation was minted. A node added afterwards looks perfect in
|
|
539
|
-
* every field here and answers 403.
|
|
540
|
-
*
|
|
541
|
-
* Omit `admitters` only for an invitation that names none — an empty list on
|
|
542
|
-
* the wire authorises any node to admit.
|
|
543
|
-
*/
|
|
544
|
-
async findAdmitter(namespaceId, admitters) {
|
|
545
|
-
const { nodes } = await this.getNamespaceRouting(namespaceId);
|
|
546
|
-
const allowed = admitters && admitters.length > 0 ? new Set(admitters.map((a) => a.toLowerCase())) : null;
|
|
547
|
-
return (nodes.find((n) => n.canAdmit &&
|
|
548
|
-
(allowed === null || (n.account !== null && allowed.has(n.account.toLowerCase())))) ?? null);
|
|
549
|
-
}
|
|
550
|
-
/**
|
|
551
|
-
* Every attested machine currently serving something this account owns.
|
|
552
|
-
*
|
|
553
|
-
* The account-wide view of the same rows {@link getNamespaceRelays} returns
|
|
554
|
-
* per namespace, keyed by machine — so a UI can answer "what is running for
|
|
555
|
-
* me?" in one call. `canExecute` folds the three-way precondition, so a
|
|
556
|
-
* caller does not re-derive it.
|
|
557
|
-
*
|
|
558
|
-
* Only the caller's own namespaces appear on each machine. A plan's
|
|
559
|
-
* `usersPerMachine` is greater than one, so a fleet node routinely serves
|
|
560
|
-
* several accounts at once.
|
|
561
|
-
*/
|
|
562
|
-
async getMyMachines() {
|
|
563
|
-
const body = await this.request('GET', '/api/cloud/me/machines');
|
|
564
|
-
return (body.machines ?? []).map((row) => {
|
|
565
|
-
const relayUrl = row.relay_url ?? null;
|
|
566
|
-
const executorAccount = row.executor_account ?? null;
|
|
567
|
-
const namespaces = (Array.isArray(row.namespaces) ? row.namespaces : []).map((n) => {
|
|
568
|
-
const ns = n;
|
|
569
|
-
return {
|
|
570
|
-
namespaceId: String(ns.namespace_id ?? ''),
|
|
571
|
-
status: String(ns.status ?? ''),
|
|
572
|
-
authorshipReady: ns.authorship_ready === true,
|
|
573
|
-
teeRole: teeRoleOf(ns),
|
|
574
|
-
fresh: ns.fresh === true,
|
|
575
|
-
confirmedAt: ns.confirmed_at ?? null,
|
|
576
|
-
lastSeenAt: ns.last_seen_at ?? null,
|
|
577
|
-
};
|
|
578
|
-
});
|
|
579
|
-
return {
|
|
580
|
-
peerId: String(row.peer_id ?? ''),
|
|
581
|
-
relayUrl,
|
|
582
|
-
executorAccount,
|
|
583
|
-
canExecute: canExecuteOf(row, relayUrl !== null && executorAccount !== null && namespaces.some((n) => n.authorshipReady)),
|
|
584
|
-
namespaces,
|
|
585
|
-
};
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Claim a namespace under this account, proving ownership.
|
|
590
|
-
*
|
|
591
|
-
* The cloud has no other way to learn a namespace exists — namespaces are
|
|
592
|
-
* created on a node, and there is deliberately no "create namespace" in the
|
|
593
|
-
* cloud. `ownershipProof` comes from
|
|
594
|
-
* `admin.issueNamespaceOwnershipProof(namespaceId, …)` on a node that is a
|
|
595
|
-
* **direct admin** of that namespace; merod refuses to issue one otherwise.
|
|
596
|
-
*
|
|
597
|
-
* Pass merod's response object straight through. The cloud accepts core's
|
|
598
|
-
* `signerPublicKey`/`signedPayload` casing as well as snake_case, so there is
|
|
599
|
-
* no re-keying step — and getting that wrong used to surface as a `422`
|
|
600
|
-
* naming three missing fields. Passing it whole also carries the `founding`
|
|
601
|
-
* and `credential` merod attaches: the cloud accepts a namespace proof only
|
|
602
|
-
* from a node of the account that founded the namespace, checked with those
|
|
603
|
-
* two, and only when that account is linked to this login. A node that
|
|
604
|
-
* predates them, or an admin that did not found the namespace, gets a `422`.
|
|
605
|
-
*
|
|
606
|
-
* Idempotent for the same account. A namespace already claimed by someone
|
|
607
|
-
* else is a `409`; a replayed proof nonce is a `403`.
|
|
608
|
-
*/
|
|
609
|
-
async claimNamespace(namespaceId, ownershipProof) {
|
|
610
|
-
await this.request('POST', '/api/cloud/namespaces/claim', {
|
|
611
|
-
body: { namespace_id: namespaceId, ownership_proof: ownershipProof },
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
/**
|
|
615
|
-
* Ask the fleet to host a claimed namespace — the step that gets a relay
|
|
616
|
-
* assigned.
|
|
617
|
-
*
|
|
618
|
-
* Carries no proof: {@link claimNamespace} already established ownership,
|
|
619
|
-
* and this is the separate "now host it" half. Idempotent.
|
|
620
|
-
*
|
|
621
|
-
* `admitterAddrs` is where the owner's own node can be dialed — take it from
|
|
622
|
-
* that node's network status with {@link admitterAddrsFromNetworkStatus}.
|
|
623
|
-
* The first fleet node placed on a namespace has nobody but the owner to ask
|
|
624
|
-
* for admission, and without these it can only broadcast and hope the
|
|
625
|
-
* owner's node — often a laptop behind a relay — hears it. They are a dial
|
|
626
|
-
* hint, not a grant: the owner's node admits by its own policy. Omit them to
|
|
627
|
-
* leave whatever the cloud already holds; pass them again when the node's
|
|
628
|
-
* relay address changes, and they replace the stored ones.
|
|
629
|
-
*
|
|
630
|
-
* Not to be confused with {@link enableHA}, which opens the cloud's web flow
|
|
631
|
-
* in a browser tab for a user to complete by hand. This is the API call.
|
|
632
|
-
*/
|
|
633
|
-
async enableNamespaceHa(namespaceId, options = {}) {
|
|
634
|
-
const body = options.admitterAddrs === undefined
|
|
635
|
-
? {}
|
|
636
|
-
: { admitter_addrs: [...options.admitterAddrs] };
|
|
637
|
-
return this.request('POST', `/api/cloud/namespaces/${pathSegment(namespaceId)}/enable-ha`, { body });
|
|
638
|
-
}
|
|
639
|
-
/**
|
|
640
|
-
* Enable HA for a namespace this ACCOUNT founded through a relay, with the
|
|
641
|
-
* founder's claim (`signAccountOwnershipClaim`) instead of a node's
|
|
642
|
-
* ownership proof: a nodeless account holds no node that could issue one.
|
|
643
|
-
* The login must be linked to that account.
|
|
644
|
-
*/
|
|
645
|
-
async enableFoundedNamespaceHa(namespaceId, ownershipProof) {
|
|
646
|
-
return this.request('POST', `/api/cloud/me/namespaces/${pathSegment(namespaceId)}/enable-ha`, { body: { groups: [], ownership_proof: ownershipProof } });
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Enable HA for a namespace this account founded, with **no cloud session**.
|
|
650
|
-
*
|
|
651
|
-
* The anonymous sibling of {@link enableFoundedNamespaceHa}: it signs the
|
|
652
|
-
* founder's claim itself ({@link signAccountHaClaim} — no `subject`, its own
|
|
653
|
-
* audience) and posts it to
|
|
654
|
-
* `/api/cloud/accounts/{accountId}/namespaces/{namespaceId}/enable-ha`. The
|
|
655
|
-
* cloud bills the user the account is linked to through the wallet, so the
|
|
656
|
-
* account must be linked to exactly one:
|
|
657
|
-
*
|
|
658
|
-
* - not linked at all → {@link AccountNotLinkedError};
|
|
659
|
-
* - linked to several → {@link AccountLinkedToSeveralUsersError}.
|
|
660
|
-
*
|
|
661
|
-
* A claim that does not verify is a `403` `HTTPError`; quota and the rest
|
|
662
|
-
* answer as the session route does. Returns the cloud's response body, the
|
|
663
|
-
* same shape the session route's enable-ha returns.
|
|
664
|
-
*/
|
|
665
|
-
async enableHaAsAccount(options) {
|
|
666
|
-
const proof = await signAccountHaClaim({
|
|
667
|
-
namespaceId: options.namespaceId,
|
|
668
|
-
accountId: options.accountId,
|
|
669
|
-
salt: options.salt,
|
|
670
|
-
credential: options.credential,
|
|
671
|
-
deviceSecret: options.deviceSecret,
|
|
672
|
-
signer: options.signer,
|
|
673
|
-
ttlMs: options.ttlMs,
|
|
674
|
-
relayUrl: options.relayUrl,
|
|
675
|
-
});
|
|
676
|
-
// The path carries the ids as the claim does — lowercase hex — so a caller
|
|
677
|
-
// that kept them uppercase does not address a different resource.
|
|
678
|
-
const account = options.accountId.toLowerCase();
|
|
679
|
-
const namespace = options.namespaceId.toLowerCase();
|
|
680
|
-
try {
|
|
681
|
-
return await this.request('POST', `/api/cloud/accounts/${pathSegment(account)}/namespaces/${pathSegment(namespace)}/enable-ha`, { body: { ownership_proof: proof }, anonymous: true });
|
|
682
|
-
}
|
|
683
|
-
catch (err) {
|
|
684
|
-
if (err instanceof HTTPError && ACCOUNT_HA_REFUSALS[err.status]) {
|
|
685
|
-
const code = conflictCode(err.bodyText);
|
|
686
|
-
const Typed = ACCOUNT_HA_REFUSALS[err.status].find((t) => t.CODE === code);
|
|
687
|
-
if (Typed && code) {
|
|
688
|
-
throw new Typed(code, err.status, err.statusText, err.url, err.headers, err.bodyText);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
throw err;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
/**
|
|
695
|
-
* Stop hosting a claimed namespace.
|
|
696
|
-
*
|
|
697
|
-
* The fleet's own reconcile loop does the rest: each assigned node notices
|
|
698
|
-
* the namespace is no longer assigned to it and **self-leaves**, which makes
|
|
699
|
-
* core evict it and purge its local data and keys. So this is not just a
|
|
700
|
-
* billing flip.
|
|
701
|
-
*/
|
|
702
|
-
async disableNamespaceHa(namespaceId) {
|
|
703
|
-
return this.request('POST', `/api/cloud/namespaces/${pathSegment(namespaceId)}/disable-ha`, { body: {} });
|
|
704
|
-
}
|
|
705
|
-
/**
|
|
706
|
-
* The node assigned to the signed-in user, or `null`.
|
|
707
|
-
*
|
|
708
|
-
* @deprecated Use {@link getMyMachines}. This reads the cloud's retired
|
|
709
|
-
* `NodeAssignment` ledger, which nothing has written since the v1
|
|
710
|
-
* retirement, so it answers `null` for every namespace-native account —
|
|
711
|
-
* which reads as "you have no machine" when the truth is "that is no longer
|
|
712
|
-
* how a machine is assigned".
|
|
713
|
-
*/
|
|
714
|
-
async getMyNode() {
|
|
715
|
-
const body = await this.request('GET', '/api/cloud/me/node');
|
|
716
|
-
return body.node ?? null;
|
|
717
|
-
}
|
|
718
|
-
enableHA(options) {
|
|
719
|
-
const params = new URLSearchParams({
|
|
720
|
-
group_id: options.groupId,
|
|
721
|
-
context_id: options.contextId,
|
|
722
|
-
});
|
|
723
|
-
if (options.redirectUrl) {
|
|
724
|
-
params.set('redirect_url', options.redirectUrl);
|
|
725
|
-
}
|
|
726
|
-
window.open(`${this.baseUrl}/enable-ha?${params.toString()}`);
|
|
727
|
-
}
|
|
728
|
-
disableHA(options) {
|
|
729
|
-
const params = new URLSearchParams({
|
|
730
|
-
group_id: options.groupId,
|
|
731
|
-
context_id: options.contextId,
|
|
732
|
-
});
|
|
733
|
-
if (options.redirectUrl) {
|
|
734
|
-
params.set('redirect_url', options.redirectUrl);
|
|
735
|
-
}
|
|
736
|
-
window.open(`${this.baseUrl}/disable-ha?${params.toString()}`);
|
|
737
|
-
}
|
|
738
|
-
/** Record a freshly-issued session and tell the app to persist it. */
|
|
739
|
-
/**
|
|
740
|
-
* Link a Calimero account to this cloud login, proving possession of its root.
|
|
741
|
-
*
|
|
742
|
-
* Two hops, folded into one call because they are useless apart: fetch a
|
|
743
|
-
* single-use challenge, then sign it with the account root and post the proof.
|
|
744
|
-
* The secret never leaves this process — only a signature over a nonce the
|
|
745
|
-
* cloud minted does.
|
|
746
|
-
*
|
|
747
|
-
* This is the binding the cloud bills against. A relay does not need it to run
|
|
748
|
-
* an intent; the cloud needs it to know whose plan a hosted node is spending,
|
|
749
|
-
* and to sign that account back in later.
|
|
750
|
-
*
|
|
751
|
-
* @param root the account root: its 64-hex secret — from
|
|
752
|
-
* {@link generateAccountRoot}, a restored recovery phrase, or a desktop app
|
|
753
|
-
* or hardware key that already holds one — or a `Signer` over a root this
|
|
754
|
-
* process cannot read, which is what a phrase-held browser root is.
|
|
755
|
-
*
|
|
756
|
-
* Idempotent for the same login. An account already linked to a *different*
|
|
757
|
-
* cloud login is a `409`; a replayed or expired challenge is a `403`; and
|
|
758
|
-
* exceeding the plan's account limit is a `402`.
|
|
759
|
-
*/
|
|
760
|
-
async linkAccount(root) {
|
|
761
|
-
const { signer, publicKey, accountId } = await resolveRoot(root);
|
|
762
|
-
const { nonce } = await this.getAccountLinkChallenge();
|
|
763
|
-
return this.submitAccountLink({
|
|
764
|
-
accountId,
|
|
765
|
-
rootPublicKey: publicKey,
|
|
766
|
-
nonce,
|
|
767
|
-
signature: await signAccountLink({ signer, nonce }),
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
/**
|
|
771
|
-
* Mint a single-use challenge for an account root to sign.
|
|
772
|
-
*
|
|
773
|
-
* The split half of {@link linkAccount}, for the case that call cannot serve:
|
|
774
|
-
* a root held by a desktop app, a hardware key or an air-gapped machine, which
|
|
775
|
-
* signs without the secret ever reaching this process. Fetch the challenge
|
|
776
|
-
* here, have the holder sign it, then hand the proof to
|
|
777
|
-
* {@link submitAccountLink}.
|
|
778
|
-
*
|
|
779
|
-
* Bound to the signed-in login and short-lived, so it is not a standing
|
|
780
|
-
* capability if it leaks — but it IS single-use, so a challenge fetched and
|
|
781
|
-
* abandoned is spent.
|
|
782
|
-
*/
|
|
783
|
-
async getAccountLinkChallenge() {
|
|
784
|
-
const body = await this.request('GET', '/api/cloud/me/accounts/challenge');
|
|
785
|
-
return { nonce: body.nonce, expiresAtMs: body.expires_at_ms ?? null };
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Post a proof assembled elsewhere. The other half of
|
|
789
|
-
* {@link getAccountLinkChallenge}.
|
|
790
|
-
*
|
|
791
|
-
* `accountId` must be the account `rootPublicKey` names — the cloud refuses a
|
|
792
|
-
* valid signature by a key that names a different account, or a caller could
|
|
793
|
-
* prove one root and link somebody else's account. Derive both from the same
|
|
794
|
-
* key rather than letting a user type either.
|
|
795
|
-
*/
|
|
796
|
-
async submitAccountLink(proof) {
|
|
797
|
-
const body = await this.request('POST', '/api/cloud/me/accounts', {
|
|
798
|
-
body: {
|
|
799
|
-
account_id: proof.accountId,
|
|
800
|
-
root_public_key: proof.rootPublicKey,
|
|
801
|
-
nonce: proof.nonce,
|
|
802
|
-
signature: proof.signature,
|
|
803
|
-
},
|
|
804
|
-
});
|
|
805
|
-
return {
|
|
806
|
-
accountId: String(body.account_id ?? proof.accountId),
|
|
807
|
-
linkedAt: body.linked_at ?? null,
|
|
808
|
-
alreadyLinked: body.already_linked === true,
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
/**
|
|
812
|
-
* Where to send someone to authorise linking this account, and what to expect
|
|
813
|
-
* back. Pairs with {@link readAccountLinkCallback} and
|
|
814
|
-
* {@link linkAccountWithGrant}.
|
|
815
|
-
*
|
|
816
|
-
* This exists because a browser app is in a bind: it holds an account root and
|
|
817
|
-
* can never hold a *first* cloud session, since only Google issues one. So the
|
|
818
|
-
* person goes to the cloud, where they are signed in, and consents there. What
|
|
819
|
-
* comes back is a grant — consent to link this one account, spendable only by
|
|
820
|
-
* whoever can sign with its root.
|
|
821
|
-
*
|
|
822
|
-
* @param callbackUrl where the portal sends the person back. Must be an origin
|
|
823
|
-
* the cloud allows; unlisted ones are refused when the grant is minted, not
|
|
824
|
-
* here. The grant arrives in the URL **fragment**, which browsers do not
|
|
825
|
-
* send to servers.
|
|
826
|
-
*
|
|
827
|
-
* Static, so it needs no client and no session: there is nothing to
|
|
828
|
-
* authenticate as yet.
|
|
829
|
-
*/
|
|
830
|
-
static async accountLinkHandoff(options) {
|
|
831
|
-
const portal = options.portalUrl.replace(/\/+$/, '');
|
|
832
|
-
const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.callbackUrl);
|
|
833
|
-
const query = new URLSearchParams({
|
|
834
|
-
'link-account': options.accountId,
|
|
835
|
-
challenge,
|
|
836
|
-
'callback-url': callbackUrl,
|
|
837
|
-
});
|
|
838
|
-
return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
|
|
839
|
-
}
|
|
840
|
-
/**
|
|
841
|
-
* Read the portal's answer out of a callback URL's fragment.
|
|
842
|
-
*
|
|
843
|
-
* Returns `{}` when there is nothing to read, so it is safe to call on every
|
|
844
|
-
* load — which is how a redirected app finds out it has been sent back.
|
|
845
|
-
*
|
|
846
|
-
* A grant is only returned when it answers a handoff this browser began, for the
|
|
847
|
-
* account that handoff named; anything else comes back as
|
|
848
|
-
* `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
|
|
849
|
-
*
|
|
850
|
-
* @param href defaults to the current location, when there is one.
|
|
851
|
-
*/
|
|
852
|
-
static readAccountLinkCallback(href) {
|
|
853
|
-
const source = href ?? (typeof window === 'undefined' ? '' : window.location.href);
|
|
854
|
-
if (!source)
|
|
855
|
-
return {};
|
|
856
|
-
let hash;
|
|
857
|
-
let search;
|
|
858
|
-
try {
|
|
859
|
-
({ hash, search } = new URL(source));
|
|
860
|
-
}
|
|
861
|
-
catch {
|
|
862
|
-
return {};
|
|
863
|
-
}
|
|
864
|
-
const params = new URLSearchParams(hash.replace(/^#/, ''));
|
|
865
|
-
const grant = params.get('grant');
|
|
866
|
-
const accountId = params.get('account');
|
|
867
|
-
const error = params.get('error');
|
|
868
|
-
const out = {};
|
|
869
|
-
if (error)
|
|
870
|
-
takeLinkVerifier(search, null);
|
|
871
|
-
if (grant) {
|
|
872
|
-
const verifier = takeLinkVerifier(search, accountId);
|
|
873
|
-
const claims = linkGrantClaims(grant);
|
|
874
|
-
if (!verifier || claims?.account !== accountId)
|
|
875
|
-
return { error: 'unexpected_callback' };
|
|
876
|
-
out.grant = grant;
|
|
877
|
-
out.login = claims.login;
|
|
878
|
-
out.verifier = verifier;
|
|
879
|
-
}
|
|
880
|
-
if (accountId)
|
|
881
|
-
out.accountId = accountId;
|
|
882
|
-
if (error)
|
|
883
|
-
out.error = error;
|
|
884
|
-
return out;
|
|
885
|
-
}
|
|
886
|
-
/**
|
|
887
|
-
* Spend a link grant: sign it with the account root and post the pair.
|
|
888
|
-
*
|
|
889
|
-
* Anonymous, and it has to be — the app doing this holds no cloud session,
|
|
890
|
-
* which is the whole reason the grant exists. The grant carries the consenting
|
|
891
|
-
* login sealed inside it, so nothing here names one; a caller-supplied email
|
|
892
|
-
* would be a field to lie in.
|
|
893
|
-
*
|
|
894
|
-
* The signature is an ordinary account-link signature with the grant as the
|
|
895
|
-
* nonce, so the same {@link signAccountLink} every other link path uses signs
|
|
896
|
-
* this one. The grant is bound to the verifier of the browser that asked for
|
|
897
|
-
* it, and is refused here, before the root signs, unless it carries that
|
|
898
|
-
* verifier's challenge and names this root's account.
|
|
899
|
-
*
|
|
900
|
-
* A `403` means the grant was bad, expired, already spent, or signed by a key
|
|
901
|
-
* that names a different account; `409` that the account belongs to another
|
|
902
|
-
* login; `402` that the plan is full.
|
|
903
|
-
*/
|
|
904
|
-
async linkAccountWithGrant(options) {
|
|
905
|
-
if (typeof options.verifier !== 'string' || !VERIFIER_HEX.test(options.verifier)) {
|
|
906
|
-
throw new Error('no verifier for this grant - start the link again from this browser');
|
|
907
|
-
}
|
|
908
|
-
const claims = linkGrantClaims(options.grant);
|
|
909
|
-
if (claims?.challenge !== (await linkChallenge(options.verifier))) {
|
|
910
|
-
throw new Error('this grant was not issued to this link request - start the link again from this browser');
|
|
911
|
-
}
|
|
912
|
-
const { signer, publicKey, accountId } = await resolveRootPair(options.rootSecret, options.signer);
|
|
913
|
-
if (claims.account !== accountId) {
|
|
914
|
-
throw new Error(`this grant is for account ${claims.account}, but the root here derives ` +
|
|
915
|
-
`${accountId} - it was minted for a different account`);
|
|
916
|
-
}
|
|
917
|
-
const body = await this.request('POST', '/api/cloud/accounts/link', {
|
|
918
|
-
body: {
|
|
919
|
-
grant: options.grant,
|
|
920
|
-
verifier: options.verifier,
|
|
921
|
-
root_public_key: publicKey,
|
|
922
|
-
signature: await signAccountLink({
|
|
923
|
-
signer,
|
|
924
|
-
nonce: options.grant,
|
|
925
|
-
}),
|
|
926
|
-
},
|
|
927
|
-
anonymous: true,
|
|
928
|
-
});
|
|
929
|
-
return {
|
|
930
|
-
accountId: String(body.account_id ?? accountId),
|
|
931
|
-
linkedAt: body.linked_at ?? null,
|
|
932
|
-
alreadyLinked: body.already_linked === true,
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
|
-
/** The accounts linked to this login, and how many the plan allows. */
|
|
936
|
-
async getMyAccounts() {
|
|
937
|
-
const body = await this.request('GET', '/api/cloud/me/accounts');
|
|
938
|
-
return {
|
|
939
|
-
accounts: (body.accounts ?? []).map((row) => ({
|
|
940
|
-
accountId: String(row.account_id ?? ''),
|
|
941
|
-
linkedAt: row.linked_at ?? null,
|
|
942
|
-
hasRecoveryEnvelope: row.has_recovery_envelope === true,
|
|
943
|
-
})),
|
|
944
|
-
limit: body.limit ?? null,
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
adoptSession(body) {
|
|
948
|
-
this.session = {
|
|
949
|
-
sessionToken: body.session_token,
|
|
950
|
-
expiresAt: body.expires_at,
|
|
951
|
-
user: body.user,
|
|
952
|
-
};
|
|
953
|
-
void this.notifySession(this.session);
|
|
954
|
-
return this.session;
|
|
955
|
-
}
|
|
956
|
-
async notifySession(session) {
|
|
957
|
-
try {
|
|
958
|
-
await this.onSession?.(session);
|
|
959
|
-
}
|
|
960
|
-
catch {
|
|
961
|
-
// A failing persistence callback must not mask the call's own result.
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
async request(method, path, options = {}) {
|
|
965
|
-
if (!options.anonymous && !this.session) {
|
|
966
|
-
throw new Error('Not signed in to Calimero Cloud: call signInWithGoogle() or construct the client with a sessionToken');
|
|
967
|
-
}
|
|
968
|
-
// Caller headers go in FIRST, so the lines below win on any collision: a
|
|
969
|
-
// per-call header must never be able to replace the session's
|
|
970
|
-
// `Authorization` or change how the body is parsed.
|
|
971
|
-
const headers = {
|
|
972
|
-
...options.headers,
|
|
973
|
-
Accept: 'application/json',
|
|
974
|
-
};
|
|
975
|
-
if (options.body !== undefined)
|
|
976
|
-
headers['Content-Type'] = 'application/json';
|
|
977
|
-
if (!options.anonymous && this.session) {
|
|
978
|
-
headers.Authorization = `Bearer ${this.session.sessionToken}`;
|
|
979
|
-
}
|
|
980
|
-
const controller = new AbortController();
|
|
981
|
-
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
982
|
-
let response;
|
|
983
|
-
try {
|
|
984
|
-
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
985
|
-
method,
|
|
986
|
-
headers,
|
|
987
|
-
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
988
|
-
signal: controller.signal,
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
catch (err) {
|
|
992
|
-
// Same shape the node clients use for a transport failure: an `HTTPError`
|
|
993
|
-
// with `status: 0`, so a caller has one error type to handle rather than
|
|
994
|
-
// one per layer.
|
|
995
|
-
throw new HTTPError(0, err instanceof Error ? err.name : 'NetworkError', `${this.baseUrl}${path}`, new Headers(), err instanceof Error ? err.message : String(err));
|
|
996
|
-
}
|
|
997
|
-
finally {
|
|
998
|
-
clearTimeout(timer);
|
|
999
|
-
}
|
|
1000
|
-
this.adoptRollingRefresh(response);
|
|
1001
|
-
if (!response.ok) {
|
|
1002
|
-
const bodyText = await response.text().catch(() => undefined);
|
|
1003
|
-
throw new HTTPError(response.status, response.statusText, `${this.baseUrl}${path}`, response.headers, bodyText);
|
|
1004
|
-
}
|
|
1005
|
-
if (options.parse === 'none' || response.status === 204)
|
|
1006
|
-
return undefined;
|
|
1007
|
-
return (await response.json());
|
|
1008
|
-
}
|
|
1009
|
-
/**
|
|
1010
|
-
* Adopt a session token the server refreshed mid-request.
|
|
1011
|
-
*
|
|
1012
|
-
* MDMA rotates the token on activity and returns the replacement in a
|
|
1013
|
-
* response header rather than making the client ask. Ignoring it costs
|
|
1014
|
-
* nothing immediately and then logs the user out a week later, which is the
|
|
1015
|
-
* kind of bug that is impossible to reproduce in a short session.
|
|
1016
|
-
*
|
|
1017
|
-
* The user identity is carried over: the header holds only a token, and the
|
|
1018
|
-
* refresh is the same logical session as the sign-in that named the user.
|
|
1019
|
-
*/
|
|
1020
|
-
adoptRollingRefresh(response) {
|
|
1021
|
-
const token = response.headers.get(REFRESH_HEADER);
|
|
1022
|
-
if (!token || !this.session)
|
|
1023
|
-
return;
|
|
1024
|
-
const expiresRaw = response.headers.get(REFRESH_EXPIRES_HEADER);
|
|
1025
|
-
const expiresAt = Number(expiresRaw);
|
|
1026
|
-
this.session = {
|
|
1027
|
-
sessionToken: token,
|
|
1028
|
-
expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : this.session.expiresAt,
|
|
1029
|
-
user: this.session.user,
|
|
1030
|
-
};
|
|
1031
|
-
void this.notifySession(this.session);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
/**
|
|
1035
|
-
* The addresses a fleet node can dial to ask this node for admission, from the
|
|
1036
|
-
* node's `GET /admin-api/network/status` (see `AdminApiClient`).
|
|
1037
|
-
*
|
|
1038
|
-
* Its `externalAddrs`, each made to end in `/p2p/<localPeerId>`: that is the
|
|
1039
|
-
* shape core dials, and an external address is usually reported without it. A
|
|
1040
|
-
* relay-circuit address already contains `/p2p/` — the relay's — so the test is
|
|
1041
|
-
* whether it ends in this node's own id, not whether it names a peer at all.
|
|
1042
|
-
* Anything that is not a multiaddr is dropped. Empty when the status names no
|
|
1043
|
-
* peer id or no external address, which is an honest "not dialable yet".
|
|
1044
|
-
*/
|
|
1045
|
-
export function admitterAddrsFromNetworkStatus(status) {
|
|
1046
|
-
const record = status && typeof status === 'object'
|
|
1047
|
-
? (status.data ?? status)
|
|
1048
|
-
: undefined;
|
|
1049
|
-
if (!record || typeof record !== 'object')
|
|
1050
|
-
return [];
|
|
1051
|
-
const peerId = record.localPeerId;
|
|
1052
|
-
const external = record.externalAddrs;
|
|
1053
|
-
if (typeof peerId !== 'string' || !peerId || !Array.isArray(external))
|
|
1054
|
-
return [];
|
|
1055
|
-
const suffix = `/p2p/${peerId}`;
|
|
1056
|
-
const out = [];
|
|
1057
|
-
for (const entry of external) {
|
|
1058
|
-
if (typeof entry !== 'string')
|
|
1059
|
-
continue;
|
|
1060
|
-
const addr = entry.trim();
|
|
1061
|
-
if (!addr.startsWith('/'))
|
|
1062
|
-
continue;
|
|
1063
|
-
const dialable = addr.endsWith(suffix) ? addr : `${addr}${suffix}`;
|
|
1064
|
-
if (!out.includes(dialable))
|
|
1065
|
-
out.push(dialable);
|
|
1066
|
-
}
|
|
1067
|
-
return out;
|
|
1068
|
-
}
|