@calimero-network/mero-js 19.6.0 → 19.7.1

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.
@@ -343,6 +343,75 @@ export declare class AdminApiClient {
343
343
  */
344
344
  setMemberCapabilities(groupId: string, identity: string, request: SetMemberCapabilitiesRequest): Promise<void>;
345
345
  setDefaultCapabilities(groupId: string, request: SetDefaultCapabilitiesRequest): Promise<void>;
346
+ /**
347
+ * Make every member admitted to `groupId` from now on able to relay a
348
+ * delegated write, without a per-node governance op each time.
349
+ *
350
+ * `CAN_AUTHOR_ON_BEHALF` is implied by nothing — not membership, not admin —
351
+ * so every group is authorship-closed and each fleet relay otherwise needs an
352
+ * admin to grant it individually, at the moment the relay is assigned and the
353
+ * admin is not watching. Setting it in the group's DEFAULT mask is core's own
354
+ * answer to that: `add_member` seeds a non-admin's capability row from the
355
+ * default, so an attested node admitted afterwards lands open.
356
+ *
357
+ * Call it on the NAMESPACE (the root group). A grant resolves through the
358
+ * membership anchor, so one at the root reaches every Open subgroup beneath
359
+ * it, which is where contexts actually live; a `Restricted` subgroup is a
360
+ * membership boundary and needs its own call.
361
+ *
362
+ * ### Two things this does not do
363
+ *
364
+ * **It is not retroactive.** The default is copied into a member's row when
365
+ * that member is admitted, so nodes already admitted keep the row they got.
366
+ * Use {@link grantAuthorship} for those — or call this at namespace creation,
367
+ * which is the only point that needs no backfill at all.
368
+ *
369
+ * **It does not reach admins.** Core seeds the default only for non-admin
370
+ * roles, and `CAN_AUTHOR_ON_BEHALF` is deliberately not implied by admin. An
371
+ * admin's own node therefore stays closed and needs {@link grantAuthorship}.
372
+ *
373
+ * ### What it widens
374
+ *
375
+ * Every future non-admin member of the group, not only attested TEE nodes, so
376
+ * any of them may be named as a warrant's `executor`. The bound worth knowing
377
+ * before deciding: an executor cannot FORGE. A delegated write is authorized
378
+ * by the author's own warrant — signed by their device key, committed to one
379
+ * context, method and exact arguments, with its nonce checked unspent — so a
380
+ * holder can only spend a warrant it was deliberately handed. What it does
381
+ * gain is the arguments in cleartext and the choice of when, or whether, to
382
+ * publish.
383
+ *
384
+ * Read-modify-write rather than a blind set: core seeds a new group's default
385
+ * with `CAN_JOIN_OPEN_SUBGROUPS`, and overwriting that would quietly stop
386
+ * every later member inheriting into Open subgroups.
387
+ *
388
+ * Returns the resulting mask and whether an op was published — it is a no-op
389
+ * when the bit is already set, so it is safe to call on every startup.
390
+ */
391
+ openToDelegatedExecution(groupId: string): Promise<{
392
+ changed: boolean;
393
+ defaultCapabilities: number;
394
+ }>;
395
+ /**
396
+ * Grant `account` the authorship bit on `groupId`, leaving its other
397
+ * capabilities alone.
398
+ *
399
+ * The per-member counterpart to {@link openToDelegatedExecution}, and what
400
+ * closes the gap that method cannot: a relay admitted BEFORE the default was
401
+ * set, and an admin's own node, which never receives the default at all.
402
+ *
403
+ * `account` is the member's ACCOUNT id (64 hex) as `listGroupMembers` returns
404
+ * it — for a fleet relay, the `account` the cloud reports for that node. Not
405
+ * the signing key that added them; both are 64 hex, so nothing but the source
406
+ * tells them apart.
407
+ *
408
+ * Read-modify-write, so it adds the bit rather than replacing the member's
409
+ * mask. A no-op when the member already holds it.
410
+ */
411
+ grantAuthorship(groupId: string, account: string): Promise<{
412
+ changed: boolean;
413
+ capabilities: number;
414
+ }>;
346
415
  setSubgroupVisibility(groupId: string, request: SetSubgroupVisibilityRequest): Promise<void>;
347
416
  setTeeAdmissionPolicy(groupId: string, request: SetTeeAdmissionPolicyRequest): Promise<void>;
348
417
  getTeeAdmissionPolicy(groupId: string): Promise<GetTeeAdmissionPolicyResponseData>;
@@ -1,4 +1,5 @@
1
1
  import { withRetry } from '../http-client/index.js';
2
+ import { CAPABILITIES, hasCap, withCap } from '../capabilities.js';
2
3
  /**
3
4
  * Helper: server wraps most responses in `{ data: T }`.
4
5
  * This extracts `.data` so callers get the inner payload directly. Null-safe so an
@@ -709,6 +710,85 @@ export class AdminApiClient {
709
710
  async setDefaultCapabilities(groupId, request) {
710
711
  await this.httpClient.put(`/admin-api/groups/${groupId}/settings/default-capabilities`, request);
711
712
  }
713
+ /**
714
+ * Make every member admitted to `groupId` from now on able to relay a
715
+ * delegated write, without a per-node governance op each time.
716
+ *
717
+ * `CAN_AUTHOR_ON_BEHALF` is implied by nothing — not membership, not admin —
718
+ * so every group is authorship-closed and each fleet relay otherwise needs an
719
+ * admin to grant it individually, at the moment the relay is assigned and the
720
+ * admin is not watching. Setting it in the group's DEFAULT mask is core's own
721
+ * answer to that: `add_member` seeds a non-admin's capability row from the
722
+ * default, so an attested node admitted afterwards lands open.
723
+ *
724
+ * Call it on the NAMESPACE (the root group). A grant resolves through the
725
+ * membership anchor, so one at the root reaches every Open subgroup beneath
726
+ * it, which is where contexts actually live; a `Restricted` subgroup is a
727
+ * membership boundary and needs its own call.
728
+ *
729
+ * ### Two things this does not do
730
+ *
731
+ * **It is not retroactive.** The default is copied into a member's row when
732
+ * that member is admitted, so nodes already admitted keep the row they got.
733
+ * Use {@link grantAuthorship} for those — or call this at namespace creation,
734
+ * which is the only point that needs no backfill at all.
735
+ *
736
+ * **It does not reach admins.** Core seeds the default only for non-admin
737
+ * roles, and `CAN_AUTHOR_ON_BEHALF` is deliberately not implied by admin. An
738
+ * admin's own node therefore stays closed and needs {@link grantAuthorship}.
739
+ *
740
+ * ### What it widens
741
+ *
742
+ * Every future non-admin member of the group, not only attested TEE nodes, so
743
+ * any of them may be named as a warrant's `executor`. The bound worth knowing
744
+ * before deciding: an executor cannot FORGE. A delegated write is authorized
745
+ * by the author's own warrant — signed by their device key, committed to one
746
+ * context, method and exact arguments, with its nonce checked unspent — so a
747
+ * holder can only spend a warrant it was deliberately handed. What it does
748
+ * gain is the arguments in cleartext and the choice of when, or whether, to
749
+ * publish.
750
+ *
751
+ * Read-modify-write rather than a blind set: core seeds a new group's default
752
+ * with `CAN_JOIN_OPEN_SUBGROUPS`, and overwriting that would quietly stop
753
+ * every later member inheriting into Open subgroups.
754
+ *
755
+ * Returns the resulting mask and whether an op was published — it is a no-op
756
+ * when the bit is already set, so it is safe to call on every startup.
757
+ */
758
+ async openToDelegatedExecution(groupId) {
759
+ const current = await this.getDefaultCapabilities(groupId);
760
+ if (hasCap(current, CAPABILITIES.CAN_AUTHOR_ON_BEHALF)) {
761
+ return { changed: false, defaultCapabilities: current };
762
+ }
763
+ const next = withCap(current, CAPABILITIES.CAN_AUTHOR_ON_BEHALF);
764
+ await this.setDefaultCapabilities(groupId, { defaultCapabilities: next });
765
+ return { changed: true, defaultCapabilities: next };
766
+ }
767
+ /**
768
+ * Grant `account` the authorship bit on `groupId`, leaving its other
769
+ * capabilities alone.
770
+ *
771
+ * The per-member counterpart to {@link openToDelegatedExecution}, and what
772
+ * closes the gap that method cannot: a relay admitted BEFORE the default was
773
+ * set, and an admin's own node, which never receives the default at all.
774
+ *
775
+ * `account` is the member's ACCOUNT id (64 hex) as `listGroupMembers` returns
776
+ * it — for a fleet relay, the `account` the cloud reports for that node. Not
777
+ * the signing key that added them; both are 64 hex, so nothing but the source
778
+ * tells them apart.
779
+ *
780
+ * Read-modify-write, so it adds the bit rather than replacing the member's
781
+ * mask. A no-op when the member already holds it.
782
+ */
783
+ async grantAuthorship(groupId, account) {
784
+ const { capabilities } = await this.getMemberCapabilities(groupId, account);
785
+ if (hasCap(capabilities, CAPABILITIES.CAN_AUTHOR_ON_BEHALF)) {
786
+ return { changed: false, capabilities };
787
+ }
788
+ const next = withCap(capabilities, CAPABILITIES.CAN_AUTHOR_ON_BEHALF);
789
+ await this.setMemberCapabilities(groupId, account, { capabilities: next });
790
+ return { changed: true, capabilities: next };
791
+ }
712
792
  async setSubgroupVisibility(groupId, request) {
713
793
  await this.httpClient.put(`/admin-api/groups/${groupId}/settings/subgroup-visibility`, request);
714
794
  }
@@ -94,6 +94,62 @@ export interface CloudRelay {
94
94
  lastSeenAt?: string | null;
95
95
  confirmedAt?: string | null;
96
96
  }
97
+ /**
98
+ * One node serving a namespace, as a caller with no cloud account sees it.
99
+ *
100
+ * The public counterpart to {@link CloudRelay}: same fleet rows, but keyed on a
101
+ * namespace id anyone holding an invitation already has, and answering both
102
+ * things a keyholder needs from a node rather than one of them.
103
+ */
104
+ export interface CloudNamespaceNode {
105
+ peerId: string;
106
+ /**
107
+ * The node's own account id, hex.
108
+ *
109
+ * Intersect this against the invitation's signed `admitters` list before
110
+ * using the node. That list is a MINT-TIME SNAPSHOT, so a node assigned after
111
+ * an invitation was created is live, healthy, listed here, and still refused
112
+ * with 403 — the cloud cannot know which invitation you hold.
113
+ */
114
+ account: string | null;
115
+ /** Base URL, or `null` when the cloud knows none yet. */
116
+ relayUrl: string | null;
117
+ /** Ready-made admission URL, so a caller never rebuilds the path. */
118
+ admitUrl: string | null;
119
+ /** `'assigned'` (admitted pending confirm) or `'active'`. */
120
+ status: string;
121
+ /** Whether the node's heartbeat for this namespace is recent. */
122
+ fresh: boolean;
123
+ /**
124
+ * Whether a signed join can be presented here now.
125
+ *
126
+ * Deliberately independent of {@link CloudNamespaceNode.authorshipReady}:
127
+ * relaying a join the joiner already signed is not authoring on anyone's
128
+ * behalf, so a node with no grant admits perfectly well.
129
+ */
130
+ canAdmit: boolean;
131
+ /** Whether the node holds `CAN_AUTHOR_ON_BEHALF` on the namespace root group. */
132
+ authorshipReady: boolean;
133
+ /**
134
+ * Whether a delegated write can be posted here now — a URL, an account, a
135
+ * fresh heartbeat and the grant.
136
+ *
137
+ * Namespace-level. A context in a RESTRICTED SUBGROUP is governed by that
138
+ * subgroup, so per-context routing remains the exact answer if a write is
139
+ * refused despite this being true.
140
+ */
141
+ canExecute: boolean;
142
+ }
143
+ /** What {@link CloudClient.getNamespaceRouting} reports. */
144
+ export interface CloudNamespaceRouting {
145
+ namespaceId: string;
146
+ /** Usable nodes first, then a stable order, so a polling caller sees no shuffle. */
147
+ nodes: CloudNamespaceNode[];
148
+ /** Whether any listed node can take a join now. */
149
+ servable: boolean;
150
+ /** Whether any listed node can take a delegated write now. */
151
+ writable: boolean;
152
+ }
97
153
  /** One namespace of the caller's that a machine is serving. */
98
154
  export interface CloudMachineNamespace {
99
155
  namespaceId: string;
@@ -247,6 +303,39 @@ export declare class CloudClient {
247
303
  * must be in place.
248
304
  */
249
305
  findExecutingRelay(namespaceId: string): Promise<CloudRelay | null>;
306
+ /**
307
+ * Where to reach a namespace without owning it: one read, both verbs.
308
+ *
309
+ * **Anonymous.** A joiner is by construction not the namespace owner and holds
310
+ * no cloud session — needing one is exactly what this path exists to avoid.
311
+ * Discovery is not authorization: merod refuses an `/admit` call unless the
312
+ * relaying node's account is on the invitation's signed `admitters` list, and
313
+ * refuses an intent unless the warrant is genuinely the member's, so naming a
314
+ * node grants the caller nothing it did not already have.
315
+ *
316
+ * A keyholder resolves a namespace to a node ONCE and then admits, reads and
317
+ * posts intents against that same node, which is why admission and writability
318
+ * are answered together rather than across two endpoints — the per-context
319
+ * read needs a context id a joiner does not yet have.
320
+ *
321
+ * An empty `nodes` list is a state, not an error: a namespace with no fleet
322
+ * assignment has no cloud node, and the joiner must use one of the
323
+ * invitation's other admitters — another admin's node, or a self-hosted peer.
324
+ */
325
+ getNamespaceRouting(namespaceId: string): Promise<CloudNamespaceRouting>;
326
+ /**
327
+ * The node to send a signed join to, chosen from those an invitation names.
328
+ *
329
+ * Pass the invitation's `admitters` and the intersection is done here, because
330
+ * skipping it is the failure this method exists to prevent: the cloud lists
331
+ * every node assigned to the namespace, while `admitters` is a snapshot signed
332
+ * when the invitation was minted. A node added afterwards looks perfect in
333
+ * every field here and answers 403.
334
+ *
335
+ * Omit `admitters` only for an invitation that names none — an empty list on
336
+ * the wire authorises any node to admit.
337
+ */
338
+ findAdmitter(namespaceId: string, admitters?: readonly string[]): Promise<CloudNamespaceNode | null>;
250
339
  /**
251
340
  * Every attested machine currently serving something this account owns.
252
341
  *
@@ -149,6 +149,64 @@ export class CloudClient {
149
149
  return (relays.find((r) => r.authorshipReady && r.relayUrl !== null && r.executorAccount !== null) ??
150
150
  null);
151
151
  }
152
+ /**
153
+ * Where to reach a namespace without owning it: one read, both verbs.
154
+ *
155
+ * **Anonymous.** A joiner is by construction not the namespace owner and holds
156
+ * no cloud session — needing one is exactly what this path exists to avoid.
157
+ * Discovery is not authorization: merod refuses an `/admit` call unless the
158
+ * relaying node's account is on the invitation's signed `admitters` list, and
159
+ * refuses an intent unless the warrant is genuinely the member's, so naming a
160
+ * node grants the caller nothing it did not already have.
161
+ *
162
+ * A keyholder resolves a namespace to a node ONCE and then admits, reads and
163
+ * posts intents against that same node, which is why admission and writability
164
+ * are answered together rather than across two endpoints — the per-context
165
+ * read needs a context id a joiner does not yet have.
166
+ *
167
+ * An empty `nodes` list is a state, not an error: a namespace with no fleet
168
+ * assignment has no cloud node, and the joiner must use one of the
169
+ * invitation's other admitters — another admin's node, or a self-hosted peer.
170
+ */
171
+ async getNamespaceRouting(namespaceId) {
172
+ const body = await this.request('GET', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/admitters`, {
173
+ anonymous: true,
174
+ });
175
+ return {
176
+ namespaceId: String(body.namespace_id ?? namespaceId),
177
+ nodes: (body.admitters ?? []).map((row) => ({
178
+ peerId: String(row.peer_id ?? ''),
179
+ account: row.account ?? null,
180
+ relayUrl: row.relay_url ?? null,
181
+ admitUrl: row.admit_url ?? null,
182
+ status: String(row.status ?? ''),
183
+ fresh: row.fresh === true,
184
+ canAdmit: row.can_admit === true,
185
+ authorshipReady: row.authorship_ready === true,
186
+ canExecute: row.can_execute === true,
187
+ })),
188
+ servable: body.servable === true,
189
+ writable: body.writable === true,
190
+ };
191
+ }
192
+ /**
193
+ * The node to send a signed join to, chosen from those an invitation names.
194
+ *
195
+ * Pass the invitation's `admitters` and the intersection is done here, because
196
+ * skipping it is the failure this method exists to prevent: the cloud lists
197
+ * every node assigned to the namespace, while `admitters` is a snapshot signed
198
+ * when the invitation was minted. A node added afterwards looks perfect in
199
+ * every field here and answers 403.
200
+ *
201
+ * Omit `admitters` only for an invitation that names none — an empty list on
202
+ * the wire authorises any node to admit.
203
+ */
204
+ async findAdmitter(namespaceId, admitters) {
205
+ const { nodes } = await this.getNamespaceRouting(namespaceId);
206
+ const allowed = admitters && admitters.length > 0 ? new Set(admitters.map((a) => a.toLowerCase())) : null;
207
+ return (nodes.find((n) => n.canAdmit &&
208
+ (allowed === null || (n.account !== null && allowed.has(n.account.toLowerCase())))) ?? null);
209
+ }
152
210
  /**
153
211
  * Every attested machine currently serving something this account owns.
154
212
  *
@@ -123,8 +123,15 @@ function explainNoRelay(namespace, relays) {
123
123
  }
124
124
  const waitingOnGrant = relays.filter((r) => !r.authorshipReady);
125
125
  if (waitingOnGrant.length === relays.length) {
126
+ // Name the call, not just the capability. The grant is a governance op only
127
+ // a namespace admin can publish, so this message is read by someone who has
128
+ // to go ask for it — and "grant CAN_AUTHOR_ON_BEHALF" alone leaves them
129
+ // relaying a constant to an admin who then has to work out the command.
130
+ // `openToDelegatedExecution` is the durable half (every relay assigned from
131
+ // then on lands open), `grantAuthorship` the one for relays already here.
126
132
  return (`every relay on namespace ${namespace.namespaceId} is waiting for the authorship grant: ` +
127
- `an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(', ') || 'the relay account'}`);
133
+ `an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(', ') || 'the relay account'} — with AdminClient.grantAuthorship(namespaceId, account) for these relays, and ` +
134
+ `AdminClient.openToDelegatedExecution(namespaceId) so relays assigned later need no further op`);
128
135
  }
129
136
  return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none reported both a URL and an executor account yet — retry shortly`;
130
137
  }
@@ -1,4 +1,4 @@
1
1
  export { CloudClient } from './cloud-client.js';
2
- export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
2
+ export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
3
3
  export { connectCloud } from './connect.js';
4
4
  export type { ConnectCloudOptions, CloudConnection } from './connect.js';