@calimero-network/mero-js 26.10.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 -12767
- 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 -12767
- 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 -686
- package/dist/admin-api/admin-client.js +0 -1225
- 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 -2041
- 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,1225 +0,0 @@
|
|
|
1
|
-
import { withRetry } from '../http-client/index.js';
|
|
2
|
-
import { pathSegment } from '../http-client/path-segment.js';
|
|
3
|
-
import { CAPABILITIES, hasCap, withCap } from '../capabilities.js';
|
|
4
|
-
import { redeemInvitation } from './redeem-invitation.js';
|
|
5
|
-
/**
|
|
6
|
-
* Helper: server wraps most responses in `{ data: T }`.
|
|
7
|
-
* This extracts `.data` so callers get the inner payload directly. Null-safe so an
|
|
8
|
-
* empty 2xx body (parsed as null) yields undefined instead of throwing.
|
|
9
|
-
*/
|
|
10
|
-
function unwrap(response) {
|
|
11
|
-
return response?.data;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Compare two version strings by SemVer 2.0 precedence, ascending: negative if
|
|
15
|
-
* `a < b`, positive if `a > b`, `0` if equal. Build metadata (`+...`) is stripped
|
|
16
|
-
* and ignored. The numeric `major.minor.patch` core is compared field-by-field
|
|
17
|
-
* (numerically when both parse, so `1.10.0 > 1.9.0`; a missing field is `0`). A
|
|
18
|
-
* version WITH a pre-release ranks below the same core without one, and
|
|
19
|
-
* pre-release identifiers compare per spec: dot-separated, numeric identifiers
|
|
20
|
-
* numerically, alphanumeric lexically by ASCII, numeric below alphanumeric, and a
|
|
21
|
-
* shorter set of identifiers loses when all preceding fields are equal.
|
|
22
|
-
*/
|
|
23
|
-
export function compareSemver(a, b) {
|
|
24
|
-
const [coreA, preA] = splitVersion(a);
|
|
25
|
-
const [coreB, preB] = splitVersion(b);
|
|
26
|
-
const na = coreA.split('.');
|
|
27
|
-
const nb = coreB.split('.');
|
|
28
|
-
const n = Math.max(na.length, nb.length);
|
|
29
|
-
for (let i = 0; i < n; i++) {
|
|
30
|
-
const c = compareSemverId(na[i] ?? '0', nb[i] ?? '0');
|
|
31
|
-
if (c !== 0)
|
|
32
|
-
return c;
|
|
33
|
-
}
|
|
34
|
-
// A pre-release ranks below the same core version with no pre-release.
|
|
35
|
-
if (preA === undefined && preB === undefined)
|
|
36
|
-
return 0;
|
|
37
|
-
if (preA === undefined)
|
|
38
|
-
return 1;
|
|
39
|
-
if (preB === undefined)
|
|
40
|
-
return -1;
|
|
41
|
-
const pa = preA.split('.');
|
|
42
|
-
const pb = preB.split('.');
|
|
43
|
-
const m = Math.max(pa.length, pb.length);
|
|
44
|
-
for (let i = 0; i < m; i++) {
|
|
45
|
-
if (i >= pa.length)
|
|
46
|
-
return -1; // shorter set loses when all preceding equal
|
|
47
|
-
if (i >= pb.length)
|
|
48
|
-
return 1;
|
|
49
|
-
const c = compareSemverId(pa[i], pb[i]);
|
|
50
|
-
if (c !== 0)
|
|
51
|
-
return c;
|
|
52
|
-
}
|
|
53
|
-
return 0;
|
|
54
|
-
}
|
|
55
|
-
/** Drop build metadata and split the pre-release tail: `1.2.3-rc.1+b` -> `['1.2.3', 'rc.1']`. */
|
|
56
|
-
function splitVersion(v) {
|
|
57
|
-
const core = v.split('+', 1)[0];
|
|
58
|
-
const dash = core.indexOf('-');
|
|
59
|
-
return dash === -1 ? [core, undefined] : [core.slice(0, dash), core.slice(dash + 1)];
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Compare one identifier: numerically when both are all-digits, else lexically by
|
|
63
|
-
* ASCII with a numeric identifier ranking below an alphanumeric one. Fallback: a
|
|
64
|
-
* non-numeric core component (loose/malformed input) is treated as a string, so
|
|
65
|
-
* comparison never throws.
|
|
66
|
-
*/
|
|
67
|
-
function compareSemverId(a, b) {
|
|
68
|
-
const na = /^\d+$/.test(a);
|
|
69
|
-
const nb = /^\d+$/.test(b);
|
|
70
|
-
if (na && nb) {
|
|
71
|
-
const d = Number(a) - Number(b);
|
|
72
|
-
return d < 0 ? -1 : d > 0 ? 1 : 0;
|
|
73
|
-
}
|
|
74
|
-
if (na)
|
|
75
|
-
return -1;
|
|
76
|
-
if (nb)
|
|
77
|
-
return 1;
|
|
78
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Fetch a URL from the (foreign-origin) registry with a per-attempt timeout and
|
|
82
|
-
* retry on transient failures. Deliberately NOT routed through the node
|
|
83
|
-
* `HttpClient`: that client attaches the node's bearer token to every request,
|
|
84
|
-
* which must never leak to the registry's origin. Each attempt times out after
|
|
85
|
-
* ~10s; network errors and 5xx are retried (2 retries, short backoff via the
|
|
86
|
-
* shared `withRetry`), while a 4xx is a definitive answer and is not retried.
|
|
87
|
-
* `kind`/`context` shape the error message so failures stay debuggable in logs.
|
|
88
|
-
*/
|
|
89
|
-
async function fetchRegistry(url, kind, context) {
|
|
90
|
-
return withRetry(async () => {
|
|
91
|
-
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
92
|
-
if (!resp.ok) {
|
|
93
|
-
// Carry `status` so withRetry retries 5xx but leaves 4xx to throw.
|
|
94
|
-
const err = new Error(`registry ${kind} fetch failed (${resp.status}) for ${context}`);
|
|
95
|
-
err.status = resp.status;
|
|
96
|
-
throw err;
|
|
97
|
-
}
|
|
98
|
-
return resp;
|
|
99
|
-
}, { attempts: 3 });
|
|
100
|
-
}
|
|
101
|
-
export class AdminApiClient {
|
|
102
|
-
constructor(httpClient) {
|
|
103
|
-
this.httpClient = httpClient;
|
|
104
|
-
}
|
|
105
|
-
// ---- Health and Status (public, no auth) ----
|
|
106
|
-
async healthCheck() {
|
|
107
|
-
return unwrap(await this.httpClient.get('/admin-api/health'));
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Whether the node has finished starting and its store answers. Public, like
|
|
111
|
-
* {@link healthCheck}; unlike it, a node that is up but not ready answers
|
|
112
|
-
* `503`, which this surfaces as an `HTTPError` whose body names the stage.
|
|
113
|
-
*/
|
|
114
|
-
async readinessCheck() {
|
|
115
|
-
return unwrap(await this.httpClient.get('/admin-api/ready'));
|
|
116
|
-
}
|
|
117
|
-
async isAuthed() {
|
|
118
|
-
return this.httpClient.get('/admin-api/is-authed');
|
|
119
|
-
}
|
|
120
|
-
// ---- Application Management ----
|
|
121
|
-
/**
|
|
122
|
-
* Install a published application by its `package@version` coordinates. The
|
|
123
|
-
* node resolves and fetches them from its own configured `[registry]`, so no
|
|
124
|
-
* URL is passed and none is accepted. A 502 means the node's source has
|
|
125
|
-
* nothing published at those coordinates. This is the discrete "download"
|
|
126
|
-
* step an Updates flow pairs with a subsequent `upgradeGroup`.
|
|
127
|
-
*/
|
|
128
|
-
async installApplication(request) {
|
|
129
|
-
return unwrap(await this.httpClient.post('/admin-api/install-application', request));
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* List a package's published versions from the registry, newest-first by
|
|
133
|
-
* semver. Reads the registry's V2 bundle listing
|
|
134
|
-
* (`GET {registry}/api/v2/bundles?package={package}`), taking each bundle's
|
|
135
|
-
* `appVersion`. Registry-side data — distinct from the node's
|
|
136
|
-
* installed-version list — and the source an Updates view compares against
|
|
137
|
-
* the running `Context.applicationVersion` to detect "a new version exists".
|
|
138
|
-
*/
|
|
139
|
-
async getRegistryVersions(registryUrl, packageName) {
|
|
140
|
-
const url = new URL('/api/v2/bundles', new URL(registryUrl).origin);
|
|
141
|
-
url.searchParams.set('package', packageName);
|
|
142
|
-
const resp = await fetchRegistry(url.toString(), 'versions', packageName);
|
|
143
|
-
const bundles = (await resp.json());
|
|
144
|
-
return (Array.isArray(bundles) ? bundles : [])
|
|
145
|
-
.map((b) => b.appVersion)
|
|
146
|
-
.filter((v) => typeof v === 'string')
|
|
147
|
-
.sort((a, b) => compareSemver(b, a));
|
|
148
|
-
}
|
|
149
|
-
async installDevApplication(request) {
|
|
150
|
-
return unwrap(await this.httpClient.post('/admin-api/install-dev-application', request));
|
|
151
|
-
}
|
|
152
|
-
async uninstallApplication(appId) {
|
|
153
|
-
return unwrap(await this.httpClient.delete(`/admin-api/applications/${pathSegment(appId)}`));
|
|
154
|
-
}
|
|
155
|
-
async listApplications() {
|
|
156
|
-
return unwrap(await this.httpClient.get('/admin-api/applications'));
|
|
157
|
-
}
|
|
158
|
-
async getApplication(appId) {
|
|
159
|
-
return unwrap(await this.httpClient.get(`/admin-api/applications/${pathSegment(appId)}`));
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Installed-blob inventory for an application — one entry per locally installed
|
|
163
|
-
* version. This is the *installed* inventory (source for a "pick a version to
|
|
164
|
-
* pin" UI); the registry equivalent is `listPackageVersions`.
|
|
165
|
-
*/
|
|
166
|
-
async listApplicationVersions(applicationId) {
|
|
167
|
-
return unwrap(await this.httpClient.get(`/admin-api/applications/${pathSegment(applicationId)}/versions`));
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* The application's embedded `wasm-abi/1` manifest. Omit `serviceName` for
|
|
171
|
-
* single-service apps; core 400s if the ABI is absent or the name is ambiguous.
|
|
172
|
-
*/
|
|
173
|
-
async getApplicationAbi(applicationId, serviceName) {
|
|
174
|
-
const query = serviceName ? `?service_name=${encodeURIComponent(serviceName)}` : '';
|
|
175
|
-
return unwrap(await this.httpClient.get(`/admin-api/applications/${pathSegment(applicationId)}/abi${query}`));
|
|
176
|
-
}
|
|
177
|
-
// ---- Package Management ----
|
|
178
|
-
async listPackages() {
|
|
179
|
-
// Core returns this flat ({ packages: [...] }), not under `data`; tolerate both.
|
|
180
|
-
const r = await this.httpClient.get('/admin-api/packages');
|
|
181
|
-
return (r?.data ?? r);
|
|
182
|
-
}
|
|
183
|
-
async listPackageVersions(packageName) {
|
|
184
|
-
// Core returns this flat ({ versions: [...] }), not under `data`; tolerate both.
|
|
185
|
-
const r = await this.httpClient.get(`/admin-api/packages/${pathSegment(packageName)}/versions`);
|
|
186
|
-
return (r?.data ?? r);
|
|
187
|
-
}
|
|
188
|
-
async getLatestPackageVersion(packageName) {
|
|
189
|
-
return this.httpClient.get(`/admin-api/packages/${pathSegment(packageName)}/latest`);
|
|
190
|
-
}
|
|
191
|
-
// ---- Context Management ----
|
|
192
|
-
async createContext(request) {
|
|
193
|
-
// Core requires `initializationParams` (no default); default it to an empty
|
|
194
|
-
// byte array so callers that pass none don't get a 400.
|
|
195
|
-
const body = { ...request, initializationParams: request.initializationParams ?? [] };
|
|
196
|
-
return unwrap(await this.httpClient.post('/admin-api/contexts', body));
|
|
197
|
-
}
|
|
198
|
-
async deleteContext(contextId) {
|
|
199
|
-
return unwrap(await this.httpClient.delete(`/admin-api/contexts/${pathSegment(contextId)}`));
|
|
200
|
-
}
|
|
201
|
-
async getContexts() {
|
|
202
|
-
return unwrap(await this.httpClient.get('/admin-api/contexts'));
|
|
203
|
-
}
|
|
204
|
-
async getContext(contextId) {
|
|
205
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/${pathSegment(contextId)}`));
|
|
206
|
-
}
|
|
207
|
-
async getContextsForApplication(applicationId) {
|
|
208
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/for-application/${pathSegment(applicationId)}`));
|
|
209
|
-
}
|
|
210
|
-
// ---- Context Identity ----
|
|
211
|
-
async generateContextIdentity() {
|
|
212
|
-
return unwrap(await this.httpClient.post('/admin-api/identity/context', {}));
|
|
213
|
-
}
|
|
214
|
-
async getContextIdentities(contextId) {
|
|
215
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/${pathSegment(contextId)}/identities`));
|
|
216
|
-
}
|
|
217
|
-
async getContextIdentitiesOwned(contextId) {
|
|
218
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/${pathSegment(contextId)}/identities-owned`));
|
|
219
|
-
}
|
|
220
|
-
// ---- Context join (group membership) ----
|
|
221
|
-
async joinContext(contextId) {
|
|
222
|
-
return unwrap(await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/join`, {}));
|
|
223
|
-
}
|
|
224
|
-
// ---- Context group / storage / sync ----
|
|
225
|
-
async getContextGroup(contextId) {
|
|
226
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/${pathSegment(contextId)}/group`));
|
|
227
|
-
}
|
|
228
|
-
async getContextStorage(contextId) {
|
|
229
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/${pathSegment(contextId)}/storage`));
|
|
230
|
-
}
|
|
231
|
-
/** Sync one context, or every context when `contextId` is omitted. */
|
|
232
|
-
async syncContext(contextId) {
|
|
233
|
-
// Sync-all is `/contexts/sync` with no trailing slash - core routes the two
|
|
234
|
-
// spellings separately and 404s the trailing one.
|
|
235
|
-
const path = contextId ? `/admin-api/contexts/sync/${pathSegment(contextId)}` : '/admin-api/contexts/sync';
|
|
236
|
-
await this.httpClient.post(path, {});
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* Kick off a full state re-pull for a context (operator recovery for a
|
|
240
|
-
* stranded context). `force` re-pulls even when the node does not flag the
|
|
241
|
-
* context as stranded.
|
|
242
|
-
*/
|
|
243
|
-
async resyncContext(contextId, request = {}) {
|
|
244
|
-
// Core's `ResyncContextApiResponse` is a flat payload (no inner `data`
|
|
245
|
-
// field), so parse the body directly — do NOT `unwrap`, or `resyncStarted`
|
|
246
|
-
// reads as undefined and the resync silently appears to never start.
|
|
247
|
-
const r = await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/resync`, request);
|
|
248
|
-
// An empty 2xx body means the resync was accepted; synthesize the result so
|
|
249
|
-
// callers always get a typed value instead of null.
|
|
250
|
-
return r ?? { contextId, resyncStarted: true };
|
|
251
|
-
}
|
|
252
|
-
async updateContextApplication(contextId, request) {
|
|
253
|
-
await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/application`, request);
|
|
254
|
-
}
|
|
255
|
-
async getContextsWithExecutorsForApplication(applicationId) {
|
|
256
|
-
// Core returns this flat as { contexts: [...] } (not a bare array under `data`).
|
|
257
|
-
const r = await this.httpClient.get(`/admin-api/contexts/with-executors/for-application/${pathSegment(applicationId)}`);
|
|
258
|
-
if (Array.isArray(r))
|
|
259
|
-
return r;
|
|
260
|
-
return (r?.contexts ??
|
|
261
|
-
r?.data ??
|
|
262
|
-
[]);
|
|
263
|
-
}
|
|
264
|
-
// ---- Blob Management ----
|
|
265
|
-
async uploadBlob(request) {
|
|
266
|
-
// Core streams the raw request body into blob storage (no JSON) and takes
|
|
267
|
-
// its params from the query string (`hash`, `context_id` — snake_case).
|
|
268
|
-
const params = new URLSearchParams();
|
|
269
|
-
if (request.hash)
|
|
270
|
-
params.set('hash', request.hash);
|
|
271
|
-
if (request.contextId)
|
|
272
|
-
params.set('context_id', request.contextId);
|
|
273
|
-
const query = params.toString();
|
|
274
|
-
const path = query ? `/admin-api/blobs?${query}` : '/admin-api/blobs';
|
|
275
|
-
// request.data (Uint8Array view | ArrayBuffer | Blob) is a valid BodyInit and
|
|
276
|
-
// is streamed verbatim — no JSON, no cast. fetch honors a Uint8Array view's
|
|
277
|
-
// byteOffset/byteLength, so subarrays upload the correct region.
|
|
278
|
-
// Core's BlobInfo is snake_case (`blob_id`); map to camelCase like deleteBlob.
|
|
279
|
-
const res = unwrap(await this.httpClient.request(path, {
|
|
280
|
-
method: 'PUT',
|
|
281
|
-
body: request.data,
|
|
282
|
-
headers: { 'Content-Type': 'application/octet-stream' },
|
|
283
|
-
}));
|
|
284
|
-
return { blobId: res.blob_id, size: res.size };
|
|
285
|
-
}
|
|
286
|
-
async deleteBlob(blobId) {
|
|
287
|
-
// Core's `BlobDeleteResponse` is a flat, snake_case payload (`{ blob_id,
|
|
288
|
-
// deleted }`) — the lone admin DTO without a camelCase rename and without an
|
|
289
|
-
// inner `data` field. Parse directly (no `unwrap`) and map to camelCase.
|
|
290
|
-
const body = await this.httpClient.delete(`/admin-api/blobs/${pathSegment(blobId)}`);
|
|
291
|
-
return { blobId: body.blob_id, deleted: body.deleted };
|
|
292
|
-
}
|
|
293
|
-
async listBlobs() {
|
|
294
|
-
// Core's BlobInfo is snake_case (`blob_id`); map to camelCase.
|
|
295
|
-
const res = unwrap(await this.httpClient.get('/admin-api/blobs'));
|
|
296
|
-
return { blobs: res.blobs.map((b) => ({ blobId: b.blob_id, size: b.size })) };
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Blob-read query params. `context_id` is the same snake_case param
|
|
300
|
-
* {@link uploadBlob} announces with; building it in one place keeps the read
|
|
301
|
-
* side from drifting from the write side.
|
|
302
|
-
*/
|
|
303
|
-
blobPath(blobId, options) {
|
|
304
|
-
if (!options?.contextId)
|
|
305
|
-
return `/admin-api/blobs/${pathSegment(blobId)}`;
|
|
306
|
-
const params = new URLSearchParams({ context_id: options.contextId });
|
|
307
|
-
return `/admin-api/blobs/${pathSegment(blobId)}?${params.toString()}`;
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Download a blob's raw bytes. `GET /admin-api/blobs/:id` streams the blob
|
|
311
|
-
* content (e.g. `application/gzip`), NOT JSON — so fetch it as an ArrayBuffer.
|
|
312
|
-
* Use {@link listBlobs} for `{ blobId, size }` metadata.
|
|
313
|
-
*
|
|
314
|
-
* Without `contextId` the read is local-only: the node answers from its own
|
|
315
|
-
* blob store and returns immediately, or 404s. Passing `contextId` opts into
|
|
316
|
-
* network discovery — the node probes that context's peers (availability
|
|
317
|
-
* nodes first) for a holder, then transfers the bytes. Budget for it: core
|
|
318
|
-
* bounds discovery by a ~30s deadline, and the transfer is on top of that, so
|
|
319
|
-
* the worst case is well past what a UI should block on unhinted.
|
|
320
|
-
*/
|
|
321
|
-
async getBlob(blobId, options) {
|
|
322
|
-
return this.httpClient.get(this.blobPath(blobId, options), {
|
|
323
|
-
parse: 'arrayBuffer',
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* Fetch a blob's metadata without downloading it. `HEAD /admin-api/blobs/:id`
|
|
328
|
-
* returns the info in response headers (size via `content-length`, plus
|
|
329
|
-
* `x-blob-id`/`x-blob-hash`/`x-blob-mime-type`).
|
|
330
|
-
*
|
|
331
|
-
* Without `contextId` this is a local-only presence check that returns
|
|
332
|
-
* immediately. Passing `contextId` opts into the same network discovery
|
|
333
|
-
* {@link getBlob} uses — presence and size answered by probing the context's
|
|
334
|
-
* peers, with no transfer — so it inherits core's ~30s discovery deadline as
|
|
335
|
-
* its worst case. A probe only carries `found` and `size`, so a peer-sourced
|
|
336
|
-
* answer has no `hash`/`mimeType`; read `source` to tell the two apart.
|
|
337
|
-
*/
|
|
338
|
-
async getBlobInfo(blobId, options) {
|
|
339
|
-
// HEAD throws (HttpError) on a non-2xx status, so we only reach here on success
|
|
340
|
-
// — the x-blob-* headers are present. Guard size against a missing/non-numeric
|
|
341
|
-
// content-length anyway (defaults to 0 rather than NaN).
|
|
342
|
-
const { headers } = await this.httpClient.head(this.blobPath(blobId, options));
|
|
343
|
-
const size = Number(headers['content-length']);
|
|
344
|
-
return {
|
|
345
|
-
blobId: headers['x-blob-id'] ?? blobId,
|
|
346
|
-
size: Number.isFinite(size) ? size : 0,
|
|
347
|
-
hash: headers['x-blob-hash'],
|
|
348
|
-
mimeType: headers['x-blob-mime-type'],
|
|
349
|
-
// Nodes that predate the context-aware HEAD omit this header entirely, so
|
|
350
|
-
// it stays undefined rather than being defaulted to 'local' — absent means
|
|
351
|
-
// "the node didn't say", which is not the same claim as "served locally".
|
|
352
|
-
source: headers['x-blob-source'],
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
// ---- Alias Management ----
|
|
356
|
-
async createContextAlias(request) {
|
|
357
|
-
return unwrap(await this.httpClient.post('/admin-api/alias/create/context', { alias: request.alias, contextId: request.contextId }));
|
|
358
|
-
}
|
|
359
|
-
async createApplicationAlias(request) {
|
|
360
|
-
return unwrap(await this.httpClient.post('/admin-api/alias/create/application', { alias: request.alias, applicationId: request.applicationId }));
|
|
361
|
-
}
|
|
362
|
-
async lookupContextAlias(name) {
|
|
363
|
-
return unwrap(await this.httpClient.post(`/admin-api/alias/lookup/context/${pathSegment(name)}`, {}));
|
|
364
|
-
}
|
|
365
|
-
async lookupApplicationAlias(name) {
|
|
366
|
-
return unwrap(await this.httpClient.post(`/admin-api/alias/lookup/application/${pathSegment(name)}`, {}));
|
|
367
|
-
}
|
|
368
|
-
async deleteContextAlias(name) {
|
|
369
|
-
return unwrap(await this.httpClient.post(`/admin-api/alias/delete/context/${pathSegment(name)}`, {}));
|
|
370
|
-
}
|
|
371
|
-
async deleteApplicationAlias(name) {
|
|
372
|
-
return unwrap(await this.httpClient.post(`/admin-api/alias/delete/application/${pathSegment(name)}`, {}));
|
|
373
|
-
}
|
|
374
|
-
async listContextAliases() {
|
|
375
|
-
return unwrap(await this.httpClient.get('/admin-api/alias/list/context'));
|
|
376
|
-
}
|
|
377
|
-
async listApplicationAliases() {
|
|
378
|
-
return unwrap(await this.httpClient.get('/admin-api/alias/list/application'));
|
|
379
|
-
}
|
|
380
|
-
async createDeviceAlias(request) {
|
|
381
|
-
return unwrap(await this.httpClient.post('/admin-api/alias/create/device', { alias: request.alias, deviceId: request.deviceId }));
|
|
382
|
-
}
|
|
383
|
-
async lookupDeviceAlias(name) {
|
|
384
|
-
return unwrap(await this.httpClient.post(`/admin-api/alias/lookup/device/${pathSegment(name)}`, {}));
|
|
385
|
-
}
|
|
386
|
-
async deleteDeviceAlias(name) {
|
|
387
|
-
return unwrap(await this.httpClient.post(`/admin-api/alias/delete/device/${pathSegment(name)}`, {}));
|
|
388
|
-
}
|
|
389
|
-
async listDeviceAliases() {
|
|
390
|
-
return unwrap(await this.httpClient.get('/admin-api/alias/list/device'));
|
|
391
|
-
}
|
|
392
|
-
// ---- Namespace Management ----
|
|
393
|
-
async listNamespaces() {
|
|
394
|
-
return unwrap(await this.httpClient.get('/admin-api/namespaces'));
|
|
395
|
-
}
|
|
396
|
-
async getNamespace(namespaceId) {
|
|
397
|
-
return unwrap(await this.httpClient.get(`/admin-api/namespaces/${pathSegment(namespaceId)}`));
|
|
398
|
-
}
|
|
399
|
-
/**
|
|
400
|
-
* Who this node is — the account it writes as, the device it is, the key it
|
|
401
|
-
* signs with, and the account root a second device pairs against.
|
|
402
|
-
*
|
|
403
|
-
* Takes no namespace, and that is the point: one root key is one account
|
|
404
|
-
* everywhere, and a node signs with one key, so none of it varies by
|
|
405
|
-
* namespace.
|
|
406
|
-
*/
|
|
407
|
-
async getNodeIdentity() {
|
|
408
|
-
return unwrap(await this.httpClient.get('/admin-api/identity'));
|
|
409
|
-
}
|
|
410
|
-
/**
|
|
411
|
-
* Ask this node to run one method on a member's behalf, under a warrant that
|
|
412
|
-
* member signed.
|
|
413
|
-
*
|
|
414
|
-
* The node executes and signs the envelope with its own key; the change is
|
|
415
|
-
* attributed to the **author**. Both halves travel together, so every peer
|
|
416
|
-
* re-checks that the member consented instead of trusting the relay.
|
|
417
|
-
*
|
|
418
|
-
* Three things have to be true first, and none is implied by the others:
|
|
419
|
-
*
|
|
420
|
-
* - the author's **account** is a member of the group owning the context —
|
|
421
|
-
* their device joins nothing and is in no group's binding rows, which is
|
|
422
|
-
* exactly the case a device certificate covers;
|
|
423
|
-
* - this node holds `CAN_AUTHOR_ON_BEHALF` on that group, which is implied by
|
|
424
|
-
* neither membership nor admin;
|
|
425
|
-
* - the warrant has not been spent. It is single-use: presenting a valid one
|
|
426
|
-
* twice is refused, because the signature stays valid forever and replay is
|
|
427
|
-
* not forgery.
|
|
428
|
-
*
|
|
429
|
-
* A refusal comes back as a `403` carrying the reason — "an admin must grant
|
|
430
|
-
* CAN_AUTHOR_ON_BEHALF to …", or that the nonce is already spent — so a caller
|
|
431
|
-
* can tell which precondition failed rather than only that one did.
|
|
432
|
-
*
|
|
433
|
-
* Mint the warrant with {@link signWarrant}; see
|
|
434
|
-
* {@link PerformIntentRequest.warrant}.
|
|
435
|
-
*/
|
|
436
|
-
async performIntent(contextId, request) {
|
|
437
|
-
return unwrap(await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/intents`, request));
|
|
438
|
-
}
|
|
439
|
-
/**
|
|
440
|
-
* Read a context as the session's ACCOUNT, without minting a warrant.
|
|
441
|
-
*
|
|
442
|
-
* Needs an account-authenticated session — one from {@link login}, not the
|
|
443
|
-
* admin password login — and refuses otherwise with a `401`. The account must
|
|
444
|
-
* be a member of the group owning the context (`403` if not), and the method
|
|
445
|
-
* must be declared read-only in the app's ABI (`409` if not; a write needs
|
|
446
|
-
* {@link performIntent}).
|
|
447
|
-
*/
|
|
448
|
-
async queryContext(contextId, request) {
|
|
449
|
-
return unwrap(await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/query`, request));
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Issue an access grant: a token whose holder may call `request.methods` in
|
|
453
|
-
* `contextId`, as this node, with no session of its own.
|
|
454
|
-
*
|
|
455
|
-
* A grant only narrows: each method must be one this session's own token may
|
|
456
|
-
* execute in the context (`403` otherwise), and an account session is refused
|
|
457
|
-
* (`403`), since a granted call runs as the node. The token is in the answer
|
|
458
|
-
* once and never again; the node keeps only its hash.
|
|
459
|
-
*/
|
|
460
|
-
async createAccessGrant(contextId, request) {
|
|
461
|
-
return unwrap(await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/grants`, { methods: request.methods, label: request.label ?? '', notAfter: request.notAfter ?? null }));
|
|
462
|
-
}
|
|
463
|
-
/** The access grants of `contextId` this session could have issued. */
|
|
464
|
-
async listAccessGrants(contextId) {
|
|
465
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/${pathSegment(contextId)}/grants`));
|
|
466
|
-
}
|
|
467
|
-
/** Revoke an access grant. A grant this session cannot see is a `404`. */
|
|
468
|
-
async revokeAccessGrant(contextId, grantId) {
|
|
469
|
-
return unwrap(await this.httpClient.delete(`/admin-api/contexts/${pathSegment(contextId)}/grants/${pathSegment(grantId)}`));
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* Call one method under an access grant. The token is the whole credential:
|
|
473
|
-
* the route is public on every node, so this works from a client holding no
|
|
474
|
-
* session at all. A method the grant does not name, an unknown or revoked
|
|
475
|
-
* token, and an expired grant are each a `403`.
|
|
476
|
-
*/
|
|
477
|
-
async callWithAccessGrant(contextId, request) {
|
|
478
|
-
return unwrap(await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/grant-calls`, request));
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* Ask what this node can do for a member in `contextId`, before they sign
|
|
482
|
-
* anything.
|
|
483
|
-
*
|
|
484
|
-
* The facts {@link performIntent} needs and a client cannot derive: whose
|
|
485
|
-
* account and key go in the warrant's `executor` and `executor_key`, which
|
|
486
|
-
* release it pins, and whether this node holds `CAN_AUTHOR_ON_BEHALF` on the
|
|
487
|
-
* owning group.
|
|
488
|
-
* `canAuthorOnBehalf: false` comes back as an answer rather than an error, as
|
|
489
|
-
* it is the default state of every context, so a caller can say "an admin of
|
|
490
|
-
* `groupId` must grant it to `executorAccount`" instead of presenting a
|
|
491
|
-
* warrant that will be refused after the author has spent a nonce on it.
|
|
492
|
-
*
|
|
493
|
-
* `404` means the context belongs to no group, this node holds no account or
|
|
494
|
-
* identity in it yet, or its group names no release yet; no warrant can be
|
|
495
|
-
* spent here until that changes.
|
|
496
|
-
*
|
|
497
|
-
* For a caller with no credential on the node at all — a browser tab holding
|
|
498
|
-
* one signing key — use `RelayClient`, which reads this same route on a node
|
|
499
|
-
* run as a relay.
|
|
500
|
-
*/
|
|
501
|
-
async getIntentRelay(contextId) {
|
|
502
|
-
return unwrap(await this.httpClient.get(`/admin-api/contexts/${pathSegment(contextId)}/intents`));
|
|
503
|
-
}
|
|
504
|
-
async listNamespacesForApplication(applicationId) {
|
|
505
|
-
return unwrap(await this.httpClient.get(`/admin-api/namespaces/for-application/${pathSegment(applicationId)}`));
|
|
506
|
-
}
|
|
507
|
-
async createNamespace(request) {
|
|
508
|
-
return unwrap(await this.httpClient.post('/admin-api/namespaces', request));
|
|
509
|
-
}
|
|
510
|
-
/** What `namespaceId` approves for outbound requests, one entry per publisher. */
|
|
511
|
-
async listOutboundApprovals(namespaceId) {
|
|
512
|
-
return unwrap(await this.httpClient.get(`/admin-api/namespaces/${pathSegment(namespaceId)}/outbound-approvals`));
|
|
513
|
-
}
|
|
514
|
-
/**
|
|
515
|
-
* Set what `namespaceId` approves for the publisher of `request.applicationId`
|
|
516
|
-
* to exactly `request.approve`: `'manifest'` or a list of URL prefixes, and
|
|
517
|
-
* `[]` revokes everything. Takes effect at the app's next run.
|
|
518
|
-
*/
|
|
519
|
-
async setOutboundApproval(namespaceId, request) {
|
|
520
|
-
return unwrap(await this.httpClient.put(`/admin-api/namespaces/${pathSegment(namespaceId)}/outbound-approvals`,
|
|
521
|
-
// Core declares this body `deny_unknown_fields`, so send exactly these.
|
|
522
|
-
{ applicationId: request.applicationId, approve: request.approve }));
|
|
523
|
-
}
|
|
524
|
-
async deleteNamespace(namespaceId) {
|
|
525
|
-
// Core requires `Content-Type: application/json` on this DELETE even though
|
|
526
|
-
// the body is always empty, so send the header and an empty JSON object.
|
|
527
|
-
return unwrap(await this.httpClient.request(`/admin-api/namespaces/${pathSegment(namespaceId)}`, {
|
|
528
|
-
method: 'DELETE',
|
|
529
|
-
body: '{}',
|
|
530
|
-
headers: { 'Content-Type': 'application/json' },
|
|
531
|
-
}));
|
|
532
|
-
}
|
|
533
|
-
async createNamespaceInvitation(namespaceId, request) {
|
|
534
|
-
return unwrap(await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/invite`, request ?? {}));
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Present a join this caller signed to a node the inviter named as an
|
|
538
|
-
* admitter.
|
|
539
|
-
*
|
|
540
|
-
* For a member with no node. `joinNamespace` publishes the membership op from
|
|
541
|
-
* the node making the call; this hands an already-signed op to somebody else's
|
|
542
|
-
* node to publish. The distinction matters because a keyholder has nowhere to
|
|
543
|
-
* publish from.
|
|
544
|
-
*
|
|
545
|
-
* The op must be signed by the **device key** in the credential it carries —
|
|
546
|
-
* every peer checks that when applying a join. So the admitter cannot author
|
|
547
|
-
* this, only carry it: a hostile one can refuse, and nothing else. It cannot
|
|
548
|
-
* admit a different account, change the group, or grant a role.
|
|
549
|
-
*
|
|
550
|
-
* @param namespaceId the namespace being joined, 64 hex
|
|
551
|
-
* @param request the invitation and the hex-encoded signed op
|
|
552
|
-
*/
|
|
553
|
-
async admitJoin(namespaceId, request) {
|
|
554
|
-
return unwrap(await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/admit`, request, { timeoutMs: 65000 }));
|
|
555
|
-
}
|
|
556
|
-
async joinNamespace(namespaceId, request) {
|
|
557
|
-
const data = unwrap(await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/join`, request, { timeoutMs: 65000 }));
|
|
558
|
-
// core 0.11.0-rc.25 renamed this response's `groupId` to `namespaceId`
|
|
559
|
-
// (core#3598). Bind whichever spelling the node actually sent rather than
|
|
560
|
-
// picking one: the rename lands as `undefined` on the field the client
|
|
561
|
-
// reads, and nothing errors — no 4xx, no thrown parse — so a client that
|
|
562
|
-
// knows only one spelling fails much later and somewhere else. Normalising
|
|
563
|
-
// here means one mero-js works against nodes either side of that release.
|
|
564
|
-
if (data && data.namespaceId === undefined && data.groupId !== undefined) {
|
|
565
|
-
return { ...data, namespaceId: data.groupId };
|
|
566
|
-
}
|
|
567
|
-
return data;
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* Bring back a namespace the user removed from this node while the account
|
|
571
|
-
* stayed a member (POST /admin-api/namespaces/:namespace_id/join, no
|
|
572
|
-
* invitation). Such namespaces are the `forgotten` ones in
|
|
573
|
-
* {@link listNamespacesForApplication}.
|
|
574
|
-
*/
|
|
575
|
-
async restoreNamespace(namespaceId) {
|
|
576
|
-
return unwrap(await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/join`, {}, { timeoutMs: 65000 }));
|
|
577
|
-
}
|
|
578
|
-
/**
|
|
579
|
-
* Join the namespace an invitation grants, and say what happened — including
|
|
580
|
-
* when the request failed but the join landed anyway.
|
|
581
|
-
*
|
|
582
|
-
* Use this rather than {@link joinNamespace} whenever someone is following an
|
|
583
|
-
* invitation. A join waits for a member to come online and can outlast any
|
|
584
|
-
* proxy in front of the node; the request then fails while the join
|
|
585
|
-
* succeeds. This sends the join once, then checks {@link listNamespaces}:
|
|
586
|
-
* a failed request whose namespace is listed is `already-member`, a success.
|
|
587
|
-
*
|
|
588
|
-
* Never throws for an ordinary failure. A failed outcome carries a `reason`
|
|
589
|
-
* read off the node's status (core rc.56+), and `retryable`, which says
|
|
590
|
-
* whether to keep the invitation for another attempt.
|
|
591
|
-
*
|
|
592
|
-
* @param namespaceId the namespace the invitation names, 64 hex
|
|
593
|
-
* @param invitation the signed invitation, as the inviter's node minted it
|
|
594
|
-
* @param options `groupName` is sent with the join; `teamName` is echoed back
|
|
595
|
-
* on a successful outcome for the caller's UI
|
|
596
|
-
*/
|
|
597
|
-
async redeemInvitation(namespaceId, invitation, options = {}) {
|
|
598
|
-
const { groupName, teamName } = options;
|
|
599
|
-
return redeemInvitation({ namespaceId, invitation, teamName }, {
|
|
600
|
-
join: async () => {
|
|
601
|
-
await this.joinNamespace(namespaceId, groupName === undefined ? { invitation } : { invitation, groupName });
|
|
602
|
-
},
|
|
603
|
-
memberships: async () => ((await this.listNamespaces()) ?? []).map((n) => n.namespaceId),
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
async createGroupInNamespace(namespaceId, request) {
|
|
607
|
-
return unwrap(await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/groups`, request ?? {}));
|
|
608
|
-
}
|
|
609
|
-
async listNamespaceGroups(namespaceId) {
|
|
610
|
-
return unwrap(await this.httpClient.get(`/admin-api/namespaces/${pathSegment(namespaceId)}/groups`));
|
|
611
|
-
}
|
|
612
|
-
// ---- Account Devices & Pairing ----
|
|
613
|
-
/**
|
|
614
|
-
* Mint this node a device for an account that already lives somewhere else -
|
|
615
|
-
* the first half of pairing, run on the joining node.
|
|
616
|
-
*
|
|
617
|
-
* Publishes nothing and needs no key: it produces the device id, the two
|
|
618
|
-
* public keys and the signature that the account holder's
|
|
619
|
-
* {@link completeAccountPairing} certifies, plus a code to read out to them
|
|
620
|
-
* over a channel the payload does not travel on. Until that second half runs,
|
|
621
|
-
* the device this mints is inert.
|
|
622
|
-
*
|
|
623
|
-
* Takes a set of namespaces rather than one, and takes it from the caller
|
|
624
|
-
* because this node cannot discover it: a node that is a member of nothing can
|
|
625
|
-
* neither read the account's namespace set off a DAG nor derive it. One device
|
|
626
|
-
* covers however many are named - the certificate is over the account, not a
|
|
627
|
-
* scope - so there is one id and one code to hand over regardless.
|
|
628
|
-
*/
|
|
629
|
-
async initAccountPairing(request) {
|
|
630
|
-
return unwrap(await this.httpClient.post('/admin-api/account/pair-init', request));
|
|
631
|
-
}
|
|
632
|
-
/**
|
|
633
|
-
* Certify a device another node minted, link it, and deliver its scope keys -
|
|
634
|
-
* the second half of pairing, run on the node that holds the account root.
|
|
635
|
-
*
|
|
636
|
-
* Every field but `applications` is copied verbatim out of that node's
|
|
637
|
-
* {@link initAccountPairing} answer. Only this side can complete it: the
|
|
638
|
-
* certificate is signed by the account root, which never leaves the node that
|
|
639
|
-
* holds it.
|
|
640
|
-
*
|
|
641
|
-
* The scope is named in applications, not namespaces, because that is the
|
|
642
|
-
* question a person can answer - a namespace is an implementation unit they
|
|
643
|
-
* never chose. Leave it out to grant every application.
|
|
644
|
-
*/
|
|
645
|
-
async completeAccountPairing(request) {
|
|
646
|
-
return unwrap(await this.httpClient.post('/admin-api/account/pair-complete', request));
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Re-run the pairing fan-out for a device this account already certified,
|
|
650
|
-
* against the namespaces this node takes part in now.
|
|
651
|
-
*
|
|
652
|
-
* Pairing is a snapshot, and this is how it is repeated. A namespace created
|
|
653
|
-
* or joined afterwards holds no binding for a device paired before it, and the
|
|
654
|
-
* symptom is silence - the device simply never sees that namespace. Nothing
|
|
655
|
-
* but the id is needed to repair it: the certificate is already held here,
|
|
656
|
-
* root-signed and naming no namespace, so a fresh endorsement and a key wrap
|
|
657
|
-
* are all a later namespace is missing. The device does not have to be online.
|
|
658
|
-
*
|
|
659
|
-
* Passing `applications` widens the device's stored scope as well as
|
|
660
|
-
* repairing. Passing none - or an empty list - repairs against the scope
|
|
661
|
-
* already stored, which is deliberately NOT the "every application" that an
|
|
662
|
-
* empty list means on {@link completeAccountPairing}: overloading it here
|
|
663
|
-
* would make the accidental request the widest one.
|
|
664
|
-
*/
|
|
665
|
-
async relinkAccountDevice(deviceId, request) {
|
|
666
|
-
return unwrap(await this.httpClient.post(`/admin-api/account/devices/${pathSegment(deviceId)}/relink`, request ?? {}));
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* Replace a device's scope outright, in one root-signed op.
|
|
670
|
-
*
|
|
671
|
-
* {@link relinkAccountDevice} only ever adds applications; this is how a
|
|
672
|
-
* scope is narrowed - or an `all` device is given a fixed list instead. The
|
|
673
|
-
* device need not be online.
|
|
674
|
-
*/
|
|
675
|
-
async rescopeAccountDevice(deviceId, request) {
|
|
676
|
-
return unwrap(await this.httpClient.put(`/admin-api/account/devices/${pathSegment(deviceId)}/scope`, request));
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* Sign a payload an outside verifier specified, with this account's root.
|
|
680
|
-
*
|
|
681
|
-
* The counterpart to a verifier that issued a challenge and will check the
|
|
682
|
-
* signature against the account id it derives — the cloud's account link and
|
|
683
|
-
* login exchanges being the ones this exists for. The root never leaves the
|
|
684
|
-
* node, so this is the only way a client can produce that signature.
|
|
685
|
-
*
|
|
686
|
-
* `domain` is a NAME from a closed set the node knows, never the domain bytes.
|
|
687
|
-
* That is the security property rather than an encoding preference: signing
|
|
688
|
-
* caller-supplied bytes under a caller-supplied prefix would be an oracle over
|
|
689
|
-
* the one key that can certify a device. A node refuses a name it does not
|
|
690
|
-
* know, and refuses one whose bytes could be extended into a credential of its
|
|
691
|
-
* own.
|
|
692
|
-
*
|
|
693
|
-
* What comes back proves possession of the root and nothing else. Whether the
|
|
694
|
-
* payload is a challenge anyone issued, whether it is fresh, and whether the
|
|
695
|
-
* holder may link this account are the verifier's to decide — it issued the
|
|
696
|
-
* challenge and knows who to.
|
|
697
|
-
*
|
|
698
|
-
* ```ts
|
|
699
|
-
* const { nonce } = await cloud.getAccountLoginChallenge();
|
|
700
|
-
* const { rootPublicKey, signature } = await admin.signWithAccountRoot({
|
|
701
|
-
* domain: 'mdma.account-login',
|
|
702
|
-
* payload: hexEncodeUtf8(nonce),
|
|
703
|
-
* });
|
|
704
|
-
* await cloud.submitAccountLogin({ rootPublicKey, nonce, signature });
|
|
705
|
-
* ```
|
|
706
|
-
*/
|
|
707
|
-
async signWithAccountRoot(request) {
|
|
708
|
-
return unwrap(await this.httpClient.post('/admin-api/account/sign-with-root', request));
|
|
709
|
-
}
|
|
710
|
-
/**
|
|
711
|
-
* Name a device of this account, for a listing to render.
|
|
712
|
-
*
|
|
713
|
-
* Run on the node holding the account root to name any device; a paired
|
|
714
|
-
* node is accepted only for that device's own id.
|
|
715
|
-
*/
|
|
716
|
-
async labelAccountDevice(deviceId, request) {
|
|
717
|
-
return unwrap(await this.httpClient.put(`/admin-api/account/devices/${pathSegment(deviceId)}/label`, request));
|
|
718
|
-
}
|
|
719
|
-
/**
|
|
720
|
-
* Every device of this account, as this node sees them - its own included.
|
|
721
|
-
*
|
|
722
|
-
* Joined from the node-local certificate cache and the live bindings of every
|
|
723
|
-
* namespace this node takes part in, so it is this node's view rather than a
|
|
724
|
-
* global one: a device certified by another holder appears with an empty
|
|
725
|
-
* `applications`, because the certificate that would name its scope is not
|
|
726
|
-
* cached here.
|
|
727
|
-
*
|
|
728
|
-
* The wire wraps this in `devices` rather than the usual `data`; the array is
|
|
729
|
-
* what a caller wants, so that wrapper is not passed on.
|
|
730
|
-
*/
|
|
731
|
-
async listAccountDevices() {
|
|
732
|
-
const response = await this.httpClient.get('/admin-api/account/devices');
|
|
733
|
-
return response.devices;
|
|
734
|
-
}
|
|
735
|
-
/**
|
|
736
|
-
* Every application this account speaks in, with the namespaces targeting each.
|
|
737
|
-
*
|
|
738
|
-
* What a device-scoping UI lists: {@link completeAccountPairing} and
|
|
739
|
-
* {@link relinkAccountDevice} both take applications, and this is where the
|
|
740
|
-
* ids they take come from. Derived from the namespaces this node takes part
|
|
741
|
-
* in, so an application installed with no namespace yet is absent - it has
|
|
742
|
-
* nothing to scope a device to until one exists.
|
|
743
|
-
*
|
|
744
|
-
* Wrapped in `applications` on the wire rather than `data`, unwrapped here for
|
|
745
|
-
* the same reason {@link listAccountDevices} unwraps `devices`.
|
|
746
|
-
*/
|
|
747
|
-
async listAccountApplications() {
|
|
748
|
-
const response = await this.httpClient.get('/admin-api/account/applications');
|
|
749
|
-
return response.applications;
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* Withdraw a device from this account, terminally.
|
|
753
|
-
*
|
|
754
|
-
* Two authorities reach the same endpoint. An admin may revoke any device and
|
|
755
|
-
* rotates the scope key as it goes. The account holder may revoke its own with
|
|
756
|
-
* a root-signed `proof`, which does not rotate - so read
|
|
757
|
-
* {@link RevokeAccountDeviceResponseData.revokedIn} rather than assuming the
|
|
758
|
-
* device lost its access: a namespace reporting `keyRotated: false` has
|
|
759
|
-
* stopped the device writing but leaves it able to read until an admin
|
|
760
|
-
* rotates.
|
|
761
|
-
*
|
|
762
|
-
* `namespaceId` names where to publish from, not the extent of the revocation:
|
|
763
|
-
* a device belongs to the account, so every namespace holding a binding for it
|
|
764
|
-
* is withdrawn from and reported back.
|
|
765
|
-
*
|
|
766
|
-
* **Revocation is forward-only.** It withdraws the device's authority from
|
|
767
|
-
* here on and does NOT retract past authorship: ops the device already signed
|
|
768
|
-
* keep verifying and stay in the DAG, because the certificate that authorised
|
|
769
|
-
* them was valid when the signature was made. Nothing in this system can
|
|
770
|
-
* un-author what a device wrote, so a UI built on this call must not offer
|
|
771
|
-
* that - "this device can no longer act" is the only claim it supports.
|
|
772
|
-
*/
|
|
773
|
-
async revokeAccountDevice(namespaceId, request) {
|
|
774
|
-
return unwrap(await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/account/revoke`, request));
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Carry the device link of an account **this node does not hold**, so the
|
|
778
|
-
* namespace binds that device's key to the account.
|
|
779
|
-
*
|
|
780
|
-
* The relay half of a nodeless account minting invitations. An account added
|
|
781
|
-
* to a group by account holds its grants but has no device bound, so peers
|
|
782
|
-
* refuse whatever its device signs as "bound to no account". The credential
|
|
783
|
-
* and scope are root-signed and self-certifying; this node adds its own
|
|
784
|
-
* member endorsement and publishes the `AccountDeviceLinked`. After that the
|
|
785
|
-
* device signs invitations itself — see `signGroupInvitation`.
|
|
786
|
-
*
|
|
787
|
-
* Refusals: `400` when a proof does not verify or the scope does not reach
|
|
788
|
-
* this namespace (re-sign it), `403` when the device was revoked here or the
|
|
789
|
-
* account is a member of nothing in the namespace (add it first).
|
|
790
|
-
*/
|
|
791
|
-
async linkAccountDevice(namespaceId, request) {
|
|
792
|
-
return unwrap(await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/account/link-device`, request));
|
|
793
|
-
}
|
|
794
|
-
// ---- Group Management ----
|
|
795
|
-
async getGroupInfo(groupId) {
|
|
796
|
-
return unwrap(await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}`));
|
|
797
|
-
}
|
|
798
|
-
/** Thin wrapper over {@link getGroupInfo}: returns the group's `defaultCapabilities` bitmask. */
|
|
799
|
-
async getDefaultCapabilities(groupId) {
|
|
800
|
-
return (await this.getGroupInfo(groupId)).defaultCapabilities;
|
|
801
|
-
}
|
|
802
|
-
/** Thin wrapper over {@link getGroupInfo}: returns the group's `subgroupVisibility`. */
|
|
803
|
-
async getSubgroupVisibility(groupId) {
|
|
804
|
-
return (await this.getGroupInfo(groupId)).subgroupVisibility;
|
|
805
|
-
}
|
|
806
|
-
async deleteGroup(groupId) {
|
|
807
|
-
// Core requires `Content-Type: application/json` on this DELETE even though
|
|
808
|
-
// the body is always empty, so send the header and an empty JSON object.
|
|
809
|
-
return unwrap(await this.httpClient.request(`/admin-api/groups/${pathSegment(groupId)}`, {
|
|
810
|
-
method: 'DELETE',
|
|
811
|
-
body: '{}',
|
|
812
|
-
headers: { 'Content-Type': 'application/json' },
|
|
813
|
-
}));
|
|
814
|
-
}
|
|
815
|
-
async listGroupMembers(groupId) {
|
|
816
|
-
const response = await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/members`);
|
|
817
|
-
// Validate the field we declare as non-optional in the type so a
|
|
818
|
-
// contract-violating response (proxy error body, future API drift,
|
|
819
|
-
// etc.) surfaces as a clear error rather than silently producing an
|
|
820
|
-
// empty list. Empty groups still satisfy this — merod returns
|
|
821
|
-
// `members: []`, not an omitted field.
|
|
822
|
-
if (!Array.isArray(response?.members)) {
|
|
823
|
-
// Sanitize before interpolation: groupId reaches us from caller code,
|
|
824
|
-
// not parsed input, but defending the message keeps untrusted bytes
|
|
825
|
-
// out of error logs and downstream UIs.
|
|
826
|
-
const safeId = String(groupId).replace(/[\r\n\t\s]/g, '').slice(0, 64);
|
|
827
|
-
throw new Error(`Invalid listGroupMembers response for group ${safeId}: missing or non-array \`members\` field`);
|
|
828
|
-
}
|
|
829
|
-
return response;
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Account -> devices for the members of a group, as its live bindings record
|
|
833
|
-
* them.
|
|
834
|
-
*
|
|
835
|
-
* The join between a group member listing, which names accounts, and a
|
|
836
|
-
* context's identities, which name bare signing keys - both 64 hex, and
|
|
837
|
-
* nothing but the source tells them apart. Batch rather than per-account
|
|
838
|
-
* because the key -> account direction gets asked about arbitrary authors,
|
|
839
|
-
* which a per-account route would answer in N calls.
|
|
840
|
-
*
|
|
841
|
-
* What comes back is **caller-scoped by the node**: an admin of the group sees
|
|
842
|
-
* every member's devices, a plain member only its own entry. A single-entry
|
|
843
|
-
* answer is therefore a statement about this caller's role, not about the
|
|
844
|
-
* group's size.
|
|
845
|
-
*
|
|
846
|
-
* Only live bindings appear, so a revoked device is absent here - use
|
|
847
|
-
* {@link listAccountDevices} for the account's own devices, which keeps a
|
|
848
|
-
* revoked one with `revoked: true`. Absence here never means the device never
|
|
849
|
-
* acted: revocation is forward-only and its past ops remain in the DAG.
|
|
850
|
-
*
|
|
851
|
-
* Wrapped in `members` on the wire rather than `data`, unwrapped here for the
|
|
852
|
-
* same reason {@link listAccountDevices} unwraps `devices`.
|
|
853
|
-
*/
|
|
854
|
-
async listGroupMemberDevices(groupId, options) {
|
|
855
|
-
// Both params are optional on the node, which clamps `limit` to its own
|
|
856
|
-
// maximum; sending them only when asked keeps the node's defaults authoritative
|
|
857
|
-
// rather than freezing today's values into this client.
|
|
858
|
-
const params = new URLSearchParams();
|
|
859
|
-
if (options?.offset !== undefined)
|
|
860
|
-
params.set('offset', String(options.offset));
|
|
861
|
-
if (options?.limit !== undefined)
|
|
862
|
-
params.set('limit', String(options.limit));
|
|
863
|
-
const query = params.toString();
|
|
864
|
-
const base = `/admin-api/groups/${pathSegment(groupId)}/member-devices`;
|
|
865
|
-
const response = await this.httpClient.get(query ? `${base}?${query}` : base);
|
|
866
|
-
return response.members;
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* Member accounts in the group and the devices bound to each. An admin node
|
|
870
|
-
* sees every account; a plain member only its own. `403` if this node is not a
|
|
871
|
-
* member of the group.
|
|
872
|
-
*/
|
|
873
|
-
async listMemberDevices(groupId, options = {}) {
|
|
874
|
-
const params = new URLSearchParams();
|
|
875
|
-
if (options.offset !== undefined)
|
|
876
|
-
params.set('offset', String(options.offset));
|
|
877
|
-
if (options.limit !== undefined)
|
|
878
|
-
params.set('limit', String(options.limit));
|
|
879
|
-
const query = params.toString();
|
|
880
|
-
const path = `/admin-api/groups/${pathSegment(groupId)}/member-devices${query ? `?${query}` : ''}`;
|
|
881
|
-
return this.httpClient.get(path);
|
|
882
|
-
}
|
|
883
|
-
/**
|
|
884
|
-
* Seal a small payload to a member account's root key. This node must be a
|
|
885
|
-
* member of the group, and `account` must be an account the group knows, so
|
|
886
|
-
* the route reaches no further than the caller already does.
|
|
887
|
-
*/
|
|
888
|
-
async sealToAccount(groupId, account, request) {
|
|
889
|
-
return unwrap(await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/accounts/${pathSegment(account)}/seal`, request));
|
|
890
|
-
}
|
|
891
|
-
async listGroupContexts(groupId) {
|
|
892
|
-
return unwrap(await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/contexts`));
|
|
893
|
-
}
|
|
894
|
-
async addGroupMembers(groupId, request) {
|
|
895
|
-
await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/members`, request);
|
|
896
|
-
}
|
|
897
|
-
async removeGroupMembers(groupId, request) {
|
|
898
|
-
await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/members/remove`, request);
|
|
899
|
-
}
|
|
900
|
-
/**
|
|
901
|
-
* `identity` is the member's ACCOUNT (64 hex), as returned by
|
|
902
|
-
* {@link listGroupMembers} — not the signing key that added them. Both are
|
|
903
|
-
* 64 hex, so nothing but the source tells them apart.
|
|
904
|
-
*/
|
|
905
|
-
async updateMemberRole(groupId, identity, request) {
|
|
906
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/members/${pathSegment(identity)}/role`, request);
|
|
907
|
-
}
|
|
908
|
-
/**
|
|
909
|
-
* `identity` is the member's ACCOUNT (64 hex), as returned by
|
|
910
|
-
* {@link listGroupMembers} — not the signing key that added them. Both are
|
|
911
|
-
* 64 hex, so nothing but the source tells them apart.
|
|
912
|
-
*/
|
|
913
|
-
async getMemberCapabilities(groupId, identity) {
|
|
914
|
-
return unwrap(await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/members/${pathSegment(identity)}/capabilities`));
|
|
915
|
-
}
|
|
916
|
-
/**
|
|
917
|
-
* `identity` is the member's ACCOUNT (64 hex), as returned by
|
|
918
|
-
* {@link listGroupMembers} — not the signing key that added them. Both are
|
|
919
|
-
* 64 hex, so nothing but the source tells them apart.
|
|
920
|
-
*/
|
|
921
|
-
async setMemberCapabilities(groupId, identity, request) {
|
|
922
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/members/${pathSegment(identity)}/capabilities`, request);
|
|
923
|
-
}
|
|
924
|
-
async setDefaultCapabilities(groupId, request) {
|
|
925
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/settings/default-capabilities`, request);
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Make every member admitted to `groupId` from now on able to relay a
|
|
929
|
-
* delegated write, without a per-node governance op each time.
|
|
930
|
-
*
|
|
931
|
-
* `CAN_AUTHOR_ON_BEHALF` is implied by nothing — not membership, not admin —
|
|
932
|
-
* so every group is authorship-closed and each fleet relay otherwise needs an
|
|
933
|
-
* admin to grant it individually, at the moment the relay is assigned and the
|
|
934
|
-
* admin is not watching. Setting it in the group's DEFAULT mask is core's own
|
|
935
|
-
* answer to that: `add_member` seeds a non-admin's capability row from the
|
|
936
|
-
* default, so an attested node admitted afterwards lands open.
|
|
937
|
-
*
|
|
938
|
-
* Call it on the NAMESPACE (the root group). A grant resolves through the
|
|
939
|
-
* membership anchor, so one at the root reaches every Open subgroup beneath
|
|
940
|
-
* it, which is where contexts actually live; a `Restricted` subgroup is a
|
|
941
|
-
* membership boundary and needs its own call.
|
|
942
|
-
*
|
|
943
|
-
* ### Two things this does not do
|
|
944
|
-
*
|
|
945
|
-
* **It is not retroactive.** The default is copied into a member's row when
|
|
946
|
-
* that member is admitted, so nodes already admitted keep the row they got.
|
|
947
|
-
* Use {@link grantAuthorship} for those — or call this at namespace creation,
|
|
948
|
-
* which is the only point that needs no backfill at all.
|
|
949
|
-
*
|
|
950
|
-
* **It does not reach admins.** Core seeds the default only for non-admin
|
|
951
|
-
* roles, and `CAN_AUTHOR_ON_BEHALF` is deliberately not implied by admin. An
|
|
952
|
-
* admin's own node therefore stays closed and needs {@link grantAuthorship}.
|
|
953
|
-
*
|
|
954
|
-
* ### What it widens
|
|
955
|
-
*
|
|
956
|
-
* Every future non-admin member of the group, not only attested TEE nodes, so
|
|
957
|
-
* any of them may be named as a warrant's `executor`. The bound worth knowing
|
|
958
|
-
* before deciding: an executor cannot FORGE. A delegated write is authorized
|
|
959
|
-
* by the author's own warrant — signed by their device key, committed to one
|
|
960
|
-
* context, method and exact arguments, with its nonce checked unspent — so a
|
|
961
|
-
* holder can only spend a warrant it was deliberately handed. What it does
|
|
962
|
-
* gain is the arguments in cleartext and the choice of when, or whether, to
|
|
963
|
-
* publish.
|
|
964
|
-
*
|
|
965
|
-
* Read-modify-write rather than a blind set: core seeds a new group's default
|
|
966
|
-
* with `CAN_JOIN_OPEN_SUBGROUPS`, and overwriting that would quietly stop
|
|
967
|
-
* every later member inheriting into Open subgroups.
|
|
968
|
-
*
|
|
969
|
-
* Returns the resulting mask and whether an op was published — it is a no-op
|
|
970
|
-
* when the bit is already set, so it is safe to call on every startup.
|
|
971
|
-
*/
|
|
972
|
-
async openToDelegatedExecution(groupId) {
|
|
973
|
-
const current = await this.getDefaultCapabilities(groupId);
|
|
974
|
-
if (hasCap(current, CAPABILITIES.CAN_AUTHOR_ON_BEHALF)) {
|
|
975
|
-
return { changed: false, defaultCapabilities: current };
|
|
976
|
-
}
|
|
977
|
-
const next = withCap(current, CAPABILITIES.CAN_AUTHOR_ON_BEHALF);
|
|
978
|
-
await this.setDefaultCapabilities(groupId, { defaultCapabilities: next });
|
|
979
|
-
return { changed: true, defaultCapabilities: next };
|
|
980
|
-
}
|
|
981
|
-
/**
|
|
982
|
-
* Grant `account` the authorship bit on `groupId`, leaving its other
|
|
983
|
-
* capabilities alone.
|
|
984
|
-
*
|
|
985
|
-
* The per-member counterpart to {@link openToDelegatedExecution}, and what
|
|
986
|
-
* closes the gap that method cannot: a relay admitted BEFORE the default was
|
|
987
|
-
* set, and an admin's own node, which never receives the default at all.
|
|
988
|
-
*
|
|
989
|
-
* `account` is the member's ACCOUNT id (64 hex) as `listGroupMembers` returns
|
|
990
|
-
* it — for a fleet relay, the `account` the cloud reports for that node. Not
|
|
991
|
-
* the signing key that added them; both are 64 hex, so nothing but the source
|
|
992
|
-
* tells them apart.
|
|
993
|
-
*
|
|
994
|
-
* Read-modify-write, so it adds the bit rather than replacing the member's
|
|
995
|
-
* mask. A no-op when the member already holds it.
|
|
996
|
-
*/
|
|
997
|
-
async grantAuthorship(groupId, account) {
|
|
998
|
-
const { capabilities } = await this.getMemberCapabilities(groupId, account);
|
|
999
|
-
if (hasCap(capabilities, CAPABILITIES.CAN_AUTHOR_ON_BEHALF)) {
|
|
1000
|
-
return { changed: false, capabilities };
|
|
1001
|
-
}
|
|
1002
|
-
const next = withCap(capabilities, CAPABILITIES.CAN_AUTHOR_ON_BEHALF);
|
|
1003
|
-
await this.setMemberCapabilities(groupId, account, { capabilities: next });
|
|
1004
|
-
return { changed: true, capabilities: next };
|
|
1005
|
-
}
|
|
1006
|
-
async setSubgroupVisibility(groupId, request) {
|
|
1007
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/settings/subgroup-visibility`, request);
|
|
1008
|
-
}
|
|
1009
|
-
async setTeeAdmissionPolicy(groupId, request) {
|
|
1010
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/settings/tee-admission-policy`, request);
|
|
1011
|
-
}
|
|
1012
|
-
async getTeeAdmissionPolicy(groupId) {
|
|
1013
|
-
// Intersection (not union) so the type is unambiguous: `data` is optional, so
|
|
1014
|
-
// `?? response` cleanly falls back to a flat (un-enveloped) response.
|
|
1015
|
-
const response = await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/settings/tee-admission-policy`);
|
|
1016
|
-
return response.data ?? response;
|
|
1017
|
-
}
|
|
1018
|
-
/**
|
|
1019
|
-
* Sets which admitted TEEs may author as the TEE authority. Pass
|
|
1020
|
-
* `{ allowedMrtd: [] }` to turn TEE authorship off. `groupId` must be a
|
|
1021
|
-
* namespace root; the node refuses a subgroup. The node exposes no read-back
|
|
1022
|
-
* route for this policy.
|
|
1023
|
-
*/
|
|
1024
|
-
async setTeeAuthoringPolicy(groupId, request) {
|
|
1025
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/settings/tee-authoring-policy`, request);
|
|
1026
|
-
}
|
|
1027
|
-
/**
|
|
1028
|
-
* Turns TEE authorship off in the namespace: every TEE loses its authority,
|
|
1029
|
-
* and admitted TEEs stay members. The node applies the same op as
|
|
1030
|
-
* `setTeeAuthoringPolicy(groupId, { allowedMrtd: [] })`. `groupId` must be a
|
|
1031
|
-
* namespace root.
|
|
1032
|
-
*
|
|
1033
|
-
* `rootProof` is the signing admin's own root proof for that empty policy
|
|
1034
|
-
* (`teeAuthoringPolicyOp([])`); omit it on a node that holds that root.
|
|
1035
|
-
*/
|
|
1036
|
-
async disableTeeAuthoringPolicy(groupId, options = {}) {
|
|
1037
|
-
const path = `/admin-api/groups/${pathSegment(groupId)}/settings/tee-authoring-policy`;
|
|
1038
|
-
if (options.rootProof === undefined) {
|
|
1039
|
-
await this.httpClient.delete(path);
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
|
-
await this.httpClient.request(path, {
|
|
1043
|
-
method: 'DELETE',
|
|
1044
|
-
body: JSON.stringify({ rootProof: options.rootProof }),
|
|
1045
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
// ---- Root-guarded owner ops (core schema 18) ----
|
|
1049
|
-
/**
|
|
1050
|
-
* Hand the group to `newOwner`, who must already be one of its admins.
|
|
1051
|
-
* Owner-only, and it needs the owner account's root proof; see
|
|
1052
|
-
* {@link RootGuardedOpRequest}.
|
|
1053
|
-
*/
|
|
1054
|
-
async transferOwnership(groupId, request) {
|
|
1055
|
-
await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/transfer-ownership`, request);
|
|
1056
|
-
}
|
|
1057
|
-
/**
|
|
1058
|
-
* Repoint the namespace's admin pin at `newAdmin`, a member of its root.
|
|
1059
|
-
* Owner-only, with the owner account's root proof; see {@link RootGuardedOpRequest}.
|
|
1060
|
-
*/
|
|
1061
|
-
async changeNamespaceAdmin(namespaceId, request) {
|
|
1062
|
-
await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/admin`, request);
|
|
1063
|
-
}
|
|
1064
|
-
/**
|
|
1065
|
-
* Delete a group through the owner-only path: it must hold no contexts, and
|
|
1066
|
-
* the op needs the owner account's root proof. {@link deleteGroup} is the
|
|
1067
|
-
* admin-level cascading delete.
|
|
1068
|
-
*/
|
|
1069
|
-
async ownerDeleteGroup(groupId, request = {}) {
|
|
1070
|
-
await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/owner-delete`, request);
|
|
1071
|
-
}
|
|
1072
|
-
// ---- Group / member / context metadata ----
|
|
1073
|
-
async setGroupMetadata(groupId, request) {
|
|
1074
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/metadata`, request);
|
|
1075
|
-
}
|
|
1076
|
-
async getGroupMetadata(groupId) {
|
|
1077
|
-
// Core single-envelopes the record: `{ data: MetadataRecord | null }`.
|
|
1078
|
-
// "No record yet" is `{ data: null }` (or a bare null body on older nodes),
|
|
1079
|
-
// so optional-chain to a clean null. Returns the full record (name + data +
|
|
1080
|
-
// updatedAt/updatedBy), not just the data map.
|
|
1081
|
-
const response = await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/metadata`);
|
|
1082
|
-
return response?.data ?? null;
|
|
1083
|
-
}
|
|
1084
|
-
async setMemberMetadata(groupId, identity, request) {
|
|
1085
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/members/${pathSegment(identity)}/metadata`, request);
|
|
1086
|
-
}
|
|
1087
|
-
/**
|
|
1088
|
-
* `identity` is the member's ACCOUNT (64 hex), as returned by
|
|
1089
|
-
* {@link listGroupMembers} — not the signing key that added them. Both are
|
|
1090
|
-
* 64 hex, so nothing but the source tells them apart.
|
|
1091
|
-
*/
|
|
1092
|
-
async getMemberMetadata(groupId, identity) {
|
|
1093
|
-
// Single-enveloped record; see getGroupMetadata.
|
|
1094
|
-
const response = await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/members/${pathSegment(identity)}/metadata`);
|
|
1095
|
-
return response?.data ?? null;
|
|
1096
|
-
}
|
|
1097
|
-
async setContextMetadata(groupId, contextId, request) {
|
|
1098
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/contexts/${pathSegment(contextId)}/metadata`, request);
|
|
1099
|
-
}
|
|
1100
|
-
async getContextMetadata(groupId, contextId) {
|
|
1101
|
-
// Single-enveloped record; see getGroupMetadata.
|
|
1102
|
-
const response = await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/contexts/${pathSegment(contextId)}/metadata`);
|
|
1103
|
-
return response?.data ?? null;
|
|
1104
|
-
}
|
|
1105
|
-
async syncGroup(groupId) {
|
|
1106
|
-
return unwrap(await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/sync`, {}));
|
|
1107
|
-
}
|
|
1108
|
-
async upgradeGroup(groupId, request) {
|
|
1109
|
-
return unwrap(await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/upgrade`, request));
|
|
1110
|
-
}
|
|
1111
|
-
async getGroupUpgradeStatus(groupId) {
|
|
1112
|
-
return unwrap(await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/upgrade/status`));
|
|
1113
|
-
}
|
|
1114
|
-
/**
|
|
1115
|
-
* The operator-facing "have all peers migrated?" rollup for a namespace.
|
|
1116
|
-
* The handler serializes the payload directly, so there is no `{ data }`
|
|
1117
|
-
* envelope to unwrap here (unlike most admin reads).
|
|
1118
|
-
*/
|
|
1119
|
-
async getMigrationStatus(namespaceId) {
|
|
1120
|
-
return this.httpClient.get(`/admin-api/groups/${pathSegment(namespaceId)}/migration-status`);
|
|
1121
|
-
}
|
|
1122
|
-
/** Revoked devices and withdrawn slots of a namespace; 403 unless this node is a bound member. */
|
|
1123
|
-
async listNamespaceRevokedDevices(namespaceId) {
|
|
1124
|
-
return this.httpClient.get(`/admin-api/namespaces/${pathSegment(namespaceId)}/revoked-devices`);
|
|
1125
|
-
}
|
|
1126
|
-
/** Per-group cascade-migration snapshots for a namespace. */
|
|
1127
|
-
async getCascadeStatus(namespaceId) {
|
|
1128
|
-
return unwrap(await this.httpClient.get(`/admin-api/groups/${pathSegment(namespaceId)}/cascade-status`));
|
|
1129
|
-
}
|
|
1130
|
-
/** Switch this node's contexts in a namespace onto its stored upgrade target; 409 if an `expected*` guard differs. */
|
|
1131
|
-
async activateGroupUpgrade(namespaceId, request = {}) {
|
|
1132
|
-
return unwrap(await this.httpClient.post(`/admin-api/groups/${pathSegment(namespaceId)}/upgrade/activate`, request));
|
|
1133
|
-
}
|
|
1134
|
-
async retryGroupUpgrade(groupId) {
|
|
1135
|
-
return unwrap(await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/upgrade/retry`, {}));
|
|
1136
|
-
}
|
|
1137
|
-
/** Move `childGroupId` under `request.newParentId`. */
|
|
1138
|
-
async reparentGroup(childGroupId, request) {
|
|
1139
|
-
// Core returns this flat ({ reparented }); tolerate the { data } envelope too.
|
|
1140
|
-
const r = await this.httpClient.post(`/admin-api/groups/${pathSegment(childGroupId)}/reparent`, request);
|
|
1141
|
-
return (r?.data ?? r);
|
|
1142
|
-
}
|
|
1143
|
-
async listSubgroups(groupId) {
|
|
1144
|
-
const response = await this.httpClient.get(`/admin-api/groups/${pathSegment(groupId)}/subgroups`);
|
|
1145
|
-
return response.subgroups ?? response.data ?? [];
|
|
1146
|
-
}
|
|
1147
|
-
async detachContextFromGroup(groupId, contextId) {
|
|
1148
|
-
await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/contexts/${pathSegment(contextId)}/remove`, {});
|
|
1149
|
-
}
|
|
1150
|
-
// ---- Group Invitation & Join ----
|
|
1151
|
-
async createGroupInvitation(groupId, request) {
|
|
1152
|
-
return unwrap(await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/invite`, request ?? {}));
|
|
1153
|
-
}
|
|
1154
|
-
async joinGroup(request) {
|
|
1155
|
-
return unwrap(await this.httpClient.post('/admin-api/groups/join', request));
|
|
1156
|
-
}
|
|
1157
|
-
async joinSubgroupInheritance(groupId) {
|
|
1158
|
-
return unwrap(await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/join-via-inheritance`, {}));
|
|
1159
|
-
}
|
|
1160
|
-
// ---- TEE ----
|
|
1161
|
-
async getTeeInfo() {
|
|
1162
|
-
return unwrap(await this.httpClient.get('/admin-api/tee/info'));
|
|
1163
|
-
}
|
|
1164
|
-
async teeAttest(request) {
|
|
1165
|
-
return unwrap(await this.httpClient.post('/admin-api/tee/attest', request));
|
|
1166
|
-
}
|
|
1167
|
-
// ---- Network ----
|
|
1168
|
-
async getPeersCount() {
|
|
1169
|
-
return this.httpClient.get('/admin-api/peers');
|
|
1170
|
-
}
|
|
1171
|
-
/** Node network status (GET /admin-api/network/status). */
|
|
1172
|
-
async getNetworkStatus() {
|
|
1173
|
-
return this.httpClient.get('/admin-api/network/status');
|
|
1174
|
-
}
|
|
1175
|
-
/** Node storage/usage stats (GET /admin-api/usage). */
|
|
1176
|
-
async getUsage() {
|
|
1177
|
-
return this.httpClient.get('/admin-api/usage');
|
|
1178
|
-
}
|
|
1179
|
-
// ---- Group / context / namespace membership ----
|
|
1180
|
-
/** Create a standalone group (POST /admin-api/groups). */
|
|
1181
|
-
async createGroup(request) {
|
|
1182
|
-
return unwrap(await this.httpClient.post('/admin-api/groups', request));
|
|
1183
|
-
}
|
|
1184
|
-
/** Leave a group (POST /admin-api/groups/:group_id/leave). */
|
|
1185
|
-
async leaveGroup(groupId) {
|
|
1186
|
-
await this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/leave`, {});
|
|
1187
|
-
}
|
|
1188
|
-
/** Leave a context (POST /admin-api/contexts/:context_id/leave). */
|
|
1189
|
-
async leaveContext(contextId) {
|
|
1190
|
-
await this.httpClient.post(`/admin-api/contexts/${pathSegment(contextId)}/leave`, {});
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Leave a namespace (POST /admin-api/namespaces/:namespace_id/leave).
|
|
1194
|
-
*
|
|
1195
|
-
* With `purge`, every device of the account also takes the namespace off its
|
|
1196
|
-
* disk. Core reads `purge` from the query string, so it is not in the body.
|
|
1197
|
-
*/
|
|
1198
|
-
async leaveNamespace(namespaceId, options = {}) {
|
|
1199
|
-
const query = options.purge ? '?purge=true' : '';
|
|
1200
|
-
await this.httpClient.post(`/admin-api/namespaces/${pathSegment(namespaceId)}/leave${query}`, {});
|
|
1201
|
-
}
|
|
1202
|
-
/** Issue a group ownership proof (POST /admin-api/groups/:group_id/issue-ownership-proof). */
|
|
1203
|
-
async issueOwnershipProof(groupId, request) {
|
|
1204
|
-
return this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/issue-ownership-proof`, request);
|
|
1205
|
-
}
|
|
1206
|
-
/** Issue a namespace ownership proof (POST /admin-api/groups/:group_id/issue-namespace-ownership-proof). */
|
|
1207
|
-
async issueNamespaceOwnershipProof(groupId, request) {
|
|
1208
|
-
return this.httpClient.post(`/admin-api/groups/${pathSegment(groupId)}/issue-namespace-ownership-proof`, request);
|
|
1209
|
-
}
|
|
1210
|
-
/**
|
|
1211
|
-
* Set a member's auto-follow flag (PUT
|
|
1212
|
-
* /admin-api/groups/:group_id/members/:identity/auto-follow).
|
|
1213
|
-
*
|
|
1214
|
-
* `identity` is the member's ACCOUNT (64 hex), as returned by
|
|
1215
|
-
* {@link listGroupMembers} — not the signing key that added them. Both are
|
|
1216
|
-
* 64 hex, so nothing but the source tells them apart.
|
|
1217
|
-
*/
|
|
1218
|
-
async setMemberAutoFollow(groupId, identity, request) {
|
|
1219
|
-
await this.httpClient.put(`/admin-api/groups/${pathSegment(groupId)}/members/${pathSegment(identity)}/auto-follow`, request);
|
|
1220
|
-
}
|
|
1221
|
-
/** Abort a namespace migration (POST /admin-api/groups/:namespace_id/migration/abort). */
|
|
1222
|
-
async abortMigration(namespaceId) {
|
|
1223
|
-
return this.httpClient.post(`/admin-api/groups/${pathSegment(namespaceId)}/migration/abort`, {});
|
|
1224
|
-
}
|
|
1225
|
-
}
|