@calimero-network/mero-js 23.8.2 → 24.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 +95 -0
- package/dist/account/account-admin.d.ts +82 -0
- package/dist/account/account-admin.js +362 -0
- package/dist/account/application-id.d.ts +48 -0
- package/dist/account/application-id.js +83 -0
- package/dist/account/bootstrap-from-invitation.d.ts +117 -0
- package/dist/account/bootstrap-from-invitation.js +86 -0
- package/dist/account/create-context.d.ts +105 -0
- package/dist/account/create-context.js +237 -0
- package/dist/account/govern.d.ts +24 -0
- package/dist/account/govern.js +50 -0
- package/dist/account/index.d.ts +18 -0
- package/dist/account/index.js +10 -0
- package/dist/account/join-as-account.d.ts +23 -0
- package/dist/account/join-as-account.js +34 -0
- package/dist/account/join-with-node.d.ts +124 -0
- package/dist/account/join-with-node.js +165 -0
- package/dist/account/node-admin.d.ts +20 -0
- package/dist/account/node-admin.js +33 -0
- package/dist/account/relay-from-invitation.d.ts +157 -0
- package/dist/account/relay-from-invitation.js +216 -0
- package/dist/account/session.d.ts +214 -0
- package/dist/account/session.js +789 -0
- package/dist/admin-api/admin-types.d.ts +6 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +4300 -2968
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/index.mjs +4290 -2968
- package/dist/index.mjs.map +4 -4
- package/dist/warrant/governance-op.d.ts +1 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -154,6 +154,101 @@ See the [cloud client](https://calimero-network.github.io/mero-js/reference/clou
|
|
|
154
154
|
[connectCloud](https://calimero-network.github.io/mero-js/reference/connect-cloud/)
|
|
155
155
|
references — including why a warrant nonce must never restart.
|
|
156
156
|
|
|
157
|
+
## Account (delegated) layer
|
|
158
|
+
|
|
159
|
+
`connectCloud` is the shape for an account that already belongs somewhere. The
|
|
160
|
+
account layer is everything around it for an account that owns nothing yet: it
|
|
161
|
+
has a device the wallet certified and no relay, and it has to found a namespace
|
|
162
|
+
or be invited into one before it can write. It is framework-free — plain TS over
|
|
163
|
+
`sessionStorage`/`localStorage` — and `@calimero-network/mero-react` re-exports
|
|
164
|
+
every name below unchanged (its hooks are built on it), so an app may import
|
|
165
|
+
them from either package.
|
|
166
|
+
|
|
167
|
+
- **Session** — `DelegatedCredential` (what the wallet certified: account,
|
|
168
|
+
certificate, device secret) and `DelegatedAccountSession` (that plus the relay
|
|
169
|
+
to write through, `null` until the account is a member of something).
|
|
170
|
+
`readDelegatedSession` / `saveDelegatedSession` / `clearDelegatedSession`,
|
|
171
|
+
`readDelegatedCredential` / `saveDelegatedCredential` /
|
|
172
|
+
`clearDelegatedCredential`; `readRelayMap`, `rememberRelay`,
|
|
173
|
+
`relayForContext`, `knownRelays`, `listDelegatedContexts`,
|
|
174
|
+
`listDelegatedNamespaces`, `carryExecutorAccount`.
|
|
175
|
+
- **The relay's node key** — `learnRelayNodeKey` (attests the relay: its signed
|
|
176
|
+
mero-tee release from the cloud mirror, Intel's chain, every register; retries
|
|
177
|
+
the transport, refuses a bad quote), `attemptRelayNodeKey`,
|
|
178
|
+
`resolveRelayNodeKey`, `pinRelayNodeKey`, `readPinnedRelayNodeKey`,
|
|
179
|
+
`RELAY_NODE_KEY_RETRY_MS`. `@phala/dcap-qvl` is loaded on the first real quote
|
|
180
|
+
and is external to the bundles: a consumer that never meets a real relay never
|
|
181
|
+
downloads the verifier.
|
|
182
|
+
- **Client** — `buildDelegatedClient(session, contextId)`: a `MeroClient` over
|
|
183
|
+
the relay transport, or `null` while there is no relay (a logged-in state, not
|
|
184
|
+
a broken one). `forgetMethodKinds`, `persistedNonces`, `markContextNonceSpent`.
|
|
185
|
+
- **Admin, one shape on both transports** — `createAccountAdmin({ session,
|
|
186
|
+
read, app })` returns an `AdminApiClient` whose writes are governance and
|
|
187
|
+
creation warrants signed by the account and whose reads go through the relay;
|
|
188
|
+
`createNodeAdmin({ admin, app })` is the same `AdminApiClient` for a node, with
|
|
189
|
+
`createNamespace` installing the package first. Refusals: `NoRelayError`,
|
|
190
|
+
`NotForAccountError`, `InvitationNotClaimableError`.
|
|
191
|
+
- **Founding and creating** — `foundDelegatedNamespace`,
|
|
192
|
+
`createDelegatedContext`, `createDelegatedPrivateContext`,
|
|
193
|
+
`delegatedGovernance`, `governGroup`, `governRoot`, `namespaceOfGroup`,
|
|
194
|
+
`rememberGroupNamespace`; `HA_REFUSAL_MESSAGES`,
|
|
195
|
+
`HA_ACCOUNT_NOT_LINKED_MESSAGE` for the cloud's HA answers.
|
|
196
|
+
- **Which application** — `applicationIdForBundle`, `selectLatestBundle`,
|
|
197
|
+
`fetchRegistryBundles`, `resolveApplicationIdFromRegistry`,
|
|
198
|
+
`latestPublishedVersion` (the registry's answer for a package).
|
|
199
|
+
- **Joining from an invitation** — `resolveRelayFromInvitation` (which relay
|
|
200
|
+
admits, from the invitation's signed admitters and the cloud), `joinWithNode`,
|
|
201
|
+
`bootstrapFromInvitation` (both steps, as a result rather than a throw),
|
|
202
|
+
`joinAsAccount` (the same, switching the session onto the relay that admitted),
|
|
203
|
+
`normaliseAccount`.
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
import {
|
|
207
|
+
beginDeviceEnrolment, readEnrolmentCallback, completeDeviceEnrolment,
|
|
208
|
+
saveDelegatedSession, learnRelayNodeKey, pinRelayNodeKey, buildDelegatedClient,
|
|
209
|
+
createAccountAdmin, joinAsAccount, resolveApplicationIdFromRegistry, type DelegatedAccountSession,
|
|
210
|
+
} from '@calimero-network/mero-js';
|
|
211
|
+
|
|
212
|
+
// 1. Enrol: the wallet certifies a device key this app generated and keeps.
|
|
213
|
+
// (first visit) beginDeviceEnrolment({ walletUrl, devicePublicKey, kemPublicKey, returnTo, state });
|
|
214
|
+
// (on return)
|
|
215
|
+
const callback = readEnrolmentCallback(window.location.href);
|
|
216
|
+
const enrolled = await completeDeviceEnrolment({ ...callback, expectState: state });
|
|
217
|
+
let session: DelegatedAccountSession = {
|
|
218
|
+
account: enrolled.account, credential: enrolled.credential, deviceSecret, relayUrl: null,
|
|
219
|
+
};
|
|
220
|
+
saveDelegatedSession(session);
|
|
221
|
+
|
|
222
|
+
// 2. Found a namespace, or join one. A new account has no relay; either gives it one.
|
|
223
|
+
const registryUrl = 'https://apps.calimero.network';
|
|
224
|
+
const app = await resolveApplicationIdFromRegistry(registryUrl, 'com.example.kv');
|
|
225
|
+
const admin = createAccountAdmin(
|
|
226
|
+
{ session, read: null, app: { packageName: 'com.example.kv', packageVersion: app.version, registryUrl } },
|
|
227
|
+
{ join: (namespaceId, invitation) => joinAsAccount(session, namespaceId, invitation, {
|
|
228
|
+
onJoined: (next) => { session = next; saveDelegatedSession(next); },
|
|
229
|
+
}) },
|
|
230
|
+
);
|
|
231
|
+
const { namespaceId } = await admin.createNamespace({ applicationId: app.applicationId, name: 'my-notes' });
|
|
232
|
+
// or: await admin.joinNamespace(namespaceId, { invitation });
|
|
233
|
+
|
|
234
|
+
// 3. Attest the relay before trusting a session on it, then write through it.
|
|
235
|
+
const nodeKey = await learnRelayNodeKey(session.relayUrl!);
|
|
236
|
+
if (nodeKey) pinRelayNodeKey(session.relayUrl!, nodeKey);
|
|
237
|
+
const client = buildDelegatedClient(session, null)!;
|
|
238
|
+
const { contextId } = await admin.createContext({ groupId: namespaceId, applicationId: app.applicationId, serviceName: 'kv' });
|
|
239
|
+
await client.execute(contextId, 'set', { key: 'greeting', value: 'hello' });
|
|
240
|
+
|
|
241
|
+
// 4. Invite another account; they claim it with `admin.joinNamespace` on their side.
|
|
242
|
+
const { invitation } = await admin.createNamespaceInvitation(namespaceId);
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`createNamespace` founds through the relay and enables HA on the cloud for the
|
|
246
|
+
account; `createContext` signs a creation warrant naming the bundle service it
|
|
247
|
+
is for; `createNamespaceInvitation` names the relays that admit, so an invitee
|
|
248
|
+
with no node of their own can be admitted by one of them. The `read` client is
|
|
249
|
+
`null` until the relay's node key is attested, which is why the session is
|
|
250
|
+
built in that order.
|
|
251
|
+
|
|
157
252
|
## Lower-level HTTP client
|
|
158
253
|
|
|
159
254
|
If you need raw HTTP against a node (without the `MeroJs` facade), the SDK
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An account's admin API: the same method names and types as mero-js's
|
|
3
|
+
* `AdminApiClient`, so an app written against a node's admin needs no second
|
|
4
|
+
* code path. Reads go to the relay's caller-scoped admin client; writes go
|
|
5
|
+
* through the relay as governance ops, delegated context and namespace
|
|
6
|
+
* creation, or an invitation the account signs itself. A write with no
|
|
7
|
+
* account form (upgrades, installs) is refused by name.
|
|
8
|
+
*/
|
|
9
|
+
import { type AdminApiClient, type SignedGroupOpenInvitation } from '../admin-api/index.js';
|
|
10
|
+
import { signGroupInvitation } from '../invitation/index.js';
|
|
11
|
+
import type { DelegatedSession } from './session.js';
|
|
12
|
+
import { governGroup, governRoot } from './govern.js';
|
|
13
|
+
import { createDelegatedContext, foundDelegatedNamespace } from './create-context.js';
|
|
14
|
+
export declare class NotForAccountError extends Error {
|
|
15
|
+
readonly method: string;
|
|
16
|
+
constructor(method: string);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* An account that has enrolled but joined nothing has no relay: nothing serves
|
|
20
|
+
* it yet. Its listings are empty, which is true; anything else names this.
|
|
21
|
+
*/
|
|
22
|
+
export declare class NoRelayError extends Error {
|
|
23
|
+
readonly method: string;
|
|
24
|
+
constructor(method: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Why an account's invitation was not minted: nobody its claimant could reach
|
|
28
|
+
* would admit them. A claimant with no node finds its admitter through the
|
|
29
|
+
* cloud's routing, intersected with the invitation's signed admitters, so an
|
|
30
|
+
* invitation the cloud routes to none of them can only fail in their hands.
|
|
31
|
+
*
|
|
32
|
+
* - `not-hosted` — the cloud serves the namespace on no node AND no relay is a
|
|
33
|
+
* member of it, so nothing admits a node-less claimant. A namespace with a
|
|
34
|
+
* relay in it is minted for even when the cloud routes it nowhere: the relay
|
|
35
|
+
* admits through its own `/admit`, and the invitation carries its address.
|
|
36
|
+
* - `no-named-node` — the cloud's nodes are not among the admitters this
|
|
37
|
+
* invitation would name.
|
|
38
|
+
*/
|
|
39
|
+
export declare class InvitationNotClaimableError extends Error {
|
|
40
|
+
readonly namespaceId: string;
|
|
41
|
+
readonly reason: 'not-hosted' | 'no-named-node';
|
|
42
|
+
constructor(namespaceId: string, reason: 'not-hosted' | 'no-named-node');
|
|
43
|
+
}
|
|
44
|
+
export interface AccountAdminDeps {
|
|
45
|
+
govern?: {
|
|
46
|
+
group: typeof governGroup;
|
|
47
|
+
root: typeof governRoot;
|
|
48
|
+
};
|
|
49
|
+
createContext?: typeof createDelegatedContext;
|
|
50
|
+
found?: typeof foundDelegatedNamespace;
|
|
51
|
+
latestVersion?: (registryUrl: string, pkg: string) => Promise<string>;
|
|
52
|
+
signInvitation?: typeof signGroupInvitation;
|
|
53
|
+
/**
|
|
54
|
+
* Redeem an invitation for this account: resolve the relay the invitation
|
|
55
|
+
* names, have it admit the account, and switch the session onto that relay.
|
|
56
|
+
* The provider supplies it (it owns the session); without it a join is
|
|
57
|
+
* refused by name. Rejects with the refusal's reason and HTTP `status`.
|
|
58
|
+
*/
|
|
59
|
+
join?: (namespaceId: string, invitation: SignedGroupOpenInvitation) => Promise<{
|
|
60
|
+
namespaceId: string;
|
|
61
|
+
}>;
|
|
62
|
+
/**
|
|
63
|
+
* The cloud's routing for a namespace (`CloudClient.getNamespaceRouting`):
|
|
64
|
+
* the nodes a node-less invitee can be admitted through. Given, an
|
|
65
|
+
* invitation is minted only if one of them is an admitter it names; a lookup
|
|
66
|
+
* that fails does not block minting. The provider supplies it.
|
|
67
|
+
*/
|
|
68
|
+
routing?: (namespaceId: string) => Promise<{
|
|
69
|
+
nodes: ReadonlyArray<{
|
|
70
|
+
account: string | null;
|
|
71
|
+
}>;
|
|
72
|
+
}>;
|
|
73
|
+
}
|
|
74
|
+
export declare function createAccountAdmin(input: {
|
|
75
|
+
session: DelegatedSession;
|
|
76
|
+
read: AdminApiClient | null;
|
|
77
|
+
app: {
|
|
78
|
+
packageName?: string;
|
|
79
|
+
packageVersion?: string;
|
|
80
|
+
registryUrl?: string;
|
|
81
|
+
};
|
|
82
|
+
}, deps?: AccountAdminDeps): AdminApiClient;
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { groupDeletedOp, groupMetadataSetOp, groupReparentedOp, memberAddedOp, memberCapabilitySetOp, memberJoinedOpenOp, memberLeftOp, memberMetadataSetOp, memberRemovedOp, contextMetadataSetOp, defaultCapabilitiesSetOp, subgroupCreation, subgroupVisibilitySetOp, memberRoleSetOp, contextDetachedOp, } from '../warrant/index.js';
|
|
2
|
+
import { signGroupInvitation, } from '../invitation/index.js';
|
|
3
|
+
import { governGroup, governRoot, namespaceOfGroup, rememberGroupNamespace } from './govern.js';
|
|
4
|
+
import { createDelegatedContext, foundDelegatedNamespace, latestPublishedVersion } from './create-context.js';
|
|
5
|
+
export class NotForAccountError extends Error {
|
|
6
|
+
constructor(method) {
|
|
7
|
+
super(`${method} is not available for an account: it needs a node`);
|
|
8
|
+
this.method = method;
|
|
9
|
+
this.name = 'NotForAccountError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* An account that has enrolled but joined nothing has no relay: nothing serves
|
|
14
|
+
* it yet. Its listings are empty, which is true; anything else names this.
|
|
15
|
+
*/
|
|
16
|
+
export class NoRelayError extends Error {
|
|
17
|
+
constructor(method) {
|
|
18
|
+
super(`${method} needs a relay, and this account has none yet: join from an invitation first`);
|
|
19
|
+
this.method = method;
|
|
20
|
+
this.name = 'NoRelayError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Why an account's invitation was not minted: nobody its claimant could reach
|
|
25
|
+
* would admit them. A claimant with no node finds its admitter through the
|
|
26
|
+
* cloud's routing, intersected with the invitation's signed admitters, so an
|
|
27
|
+
* invitation the cloud routes to none of them can only fail in their hands.
|
|
28
|
+
*
|
|
29
|
+
* - `not-hosted` — the cloud serves the namespace on no node AND no relay is a
|
|
30
|
+
* member of it, so nothing admits a node-less claimant. A namespace with a
|
|
31
|
+
* relay in it is minted for even when the cloud routes it nowhere: the relay
|
|
32
|
+
* admits through its own `/admit`, and the invitation carries its address.
|
|
33
|
+
* - `no-named-node` — the cloud's nodes are not among the admitters this
|
|
34
|
+
* invitation would name.
|
|
35
|
+
*/
|
|
36
|
+
export class InvitationNotClaimableError extends Error {
|
|
37
|
+
constructor(namespaceId, reason) {
|
|
38
|
+
super(reason === 'not-hosted'
|
|
39
|
+
? `nobody could claim an invitation to ${namespaceId} yet: the namespace is not hosted in the cloud and has no relay in it, so an invitee with no node has no relay to be admitted through. Link this account to your cloud user in the wallet and enable HA for the namespace, then invite.`
|
|
40
|
+
: `nobody could claim an invitation to ${namespaceId}: the cloud routes to none of the relays or admins it would name as admitters`);
|
|
41
|
+
this.namespaceId = namespaceId;
|
|
42
|
+
this.reason = reason;
|
|
43
|
+
this.name = 'InvitationNotClaimableError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** What an account with no relay answers: it is a member of nothing. */
|
|
47
|
+
const EMPTY_READS = {
|
|
48
|
+
listNamespaces: async () => [],
|
|
49
|
+
listNamespacesForApplication: async () => [],
|
|
50
|
+
getContexts: async () => ({ contexts: [] }),
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Calls with no account form: refused by name, so an app sees why rather than
|
|
54
|
+
* the bare 403 a relay gives a call its session may not make. Everything not
|
|
55
|
+
* listed and not overridden below is a read, answered by the relay.
|
|
56
|
+
*
|
|
57
|
+
* - Upgrades, installs, a node's own context identity: a node's to do.
|
|
58
|
+
* - `deleteContext`: a node drops its own copy and the group keeps the context.
|
|
59
|
+
* An account's copy is the relay's, shared with every account it serves;
|
|
60
|
+
* removing a context for the group is `detachContextFromGroup` or
|
|
61
|
+
* `deleteGroup`.
|
|
62
|
+
* - Aliases, read or written: they are a node's own names, and a relay's are the
|
|
63
|
+
* relay's. Core gives an account session no alias permission.
|
|
64
|
+
* - Deleting a namespace, legacy group invitations and joins, TEE policy, blob
|
|
65
|
+
* deletion: core has no delegated form for them.
|
|
66
|
+
* - Account devices: the wallet manages them, not an app.
|
|
67
|
+
*
|
|
68
|
+
* Upgrade, cascade and migration status are NOT here: core serves an account
|
|
69
|
+
* its own groups' status (#4392, rc.76), and migration status to the account
|
|
70
|
+
* that administers the namespace (#4400, rc.77), so they go to the relay.
|
|
71
|
+
*/
|
|
72
|
+
const NODE_ONLY = new Set([
|
|
73
|
+
'upgradeGroup', 'retryGroupUpgrade', 'abortMigration', 'installApplication', 'installDevApplication',
|
|
74
|
+
'uninstallApplication', 'generateContextIdentity', 'deleteContext',
|
|
75
|
+
// A node's leaveContext is a local opt-out (a tombstone, its own identity rows)
|
|
76
|
+
// and publishes nothing; an account has nothing local to opt out of, and
|
|
77
|
+
// leaving the context's group instead is leaveGroup.
|
|
78
|
+
'leaveContext',
|
|
79
|
+
'createContextAlias', 'createApplicationAlias', 'createDeviceAlias',
|
|
80
|
+
'deleteContextAlias', 'deleteApplicationAlias', 'deleteDeviceAlias',
|
|
81
|
+
'lookupContextAlias', 'lookupApplicationAlias', 'lookupDeviceAlias',
|
|
82
|
+
'listContextAliases', 'listApplicationAliases', 'listDeviceAliases',
|
|
83
|
+
'deleteNamespace', 'createGroupInvitation', 'joinGroup',
|
|
84
|
+
'setTeeAdmissionPolicy', 'getTeeAdmissionPolicy', 'deleteBlob',
|
|
85
|
+
'listAccountDevices', 'revokeAccountDevice',
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Core's refusal of a MemberJoinedOpen from someone already a direct member
|
|
89
|
+
* (`AlreadyDirectMember`, answered 409): "signer … is a direct member; use
|
|
90
|
+
* MemberJoined or add_group_members instead". Not to be confused with "is not a
|
|
91
|
+
* direct member", which a leave by an inherited member gets.
|
|
92
|
+
*/
|
|
93
|
+
function isAlreadyDirectMember(e) {
|
|
94
|
+
if (!(e instanceof Error))
|
|
95
|
+
return false;
|
|
96
|
+
const status = e.status;
|
|
97
|
+
return (status === undefined || status === 409) && /signer [0-9a-f]+ is a direct member/i.test(e.message);
|
|
98
|
+
}
|
|
99
|
+
export function createAccountAdmin(input, deps = {}) {
|
|
100
|
+
const { session: s, read, app } = input;
|
|
101
|
+
const governGroupOp = deps.govern?.group ?? governGroup;
|
|
102
|
+
const governRootOp = deps.govern?.root ?? governRoot;
|
|
103
|
+
const createContext = deps.createContext ?? createDelegatedContext;
|
|
104
|
+
const found = deps.found ?? foundDelegatedNamespace;
|
|
105
|
+
const latestVersion = deps.latestVersion ?? latestPublishedVersion;
|
|
106
|
+
const signInvitation = deps.signInvitation ?? signGroupInvitation;
|
|
107
|
+
const join = deps.join;
|
|
108
|
+
const routing = deps.routing;
|
|
109
|
+
// Reachable with or without a relay: the join is how an account gets one.
|
|
110
|
+
async function joinNamespace(namespaceId, req) {
|
|
111
|
+
if (!join)
|
|
112
|
+
throw new NotForAccountError('joinNamespace without a join handler');
|
|
113
|
+
return join(namespaceId, req.invitation);
|
|
114
|
+
}
|
|
115
|
+
// Only reached through the relay proxy below, so a null here is unreachable;
|
|
116
|
+
// the guard keeps the types honest rather than asserting it away.
|
|
117
|
+
const relay = () => {
|
|
118
|
+
if (read === null)
|
|
119
|
+
throw new NoRelayError('this call');
|
|
120
|
+
return read;
|
|
121
|
+
};
|
|
122
|
+
// A group or root op is posted to the group's namespace. A subgroup this
|
|
123
|
+
// account neither created nor joined (an open channel someone else made) is
|
|
124
|
+
// one the relay can name: ask it once, as `createContext` does, and remember.
|
|
125
|
+
async function knowNamespaceOf(groupId) {
|
|
126
|
+
if (namespaceOfGroup(s, groupId))
|
|
127
|
+
return;
|
|
128
|
+
const info = await relay().getGroupInfo(groupId);
|
|
129
|
+
rememberGroupNamespace(s.account, groupId, info.namespaceId ?? groupId);
|
|
130
|
+
}
|
|
131
|
+
const group = async (session, groupId, op) => {
|
|
132
|
+
await knowNamespaceOf(groupId);
|
|
133
|
+
return governGroupOp(session, groupId, op);
|
|
134
|
+
};
|
|
135
|
+
const root = async (session, groupId, op) => {
|
|
136
|
+
await knowNamespaceOf(groupId);
|
|
137
|
+
return governRootOp(session, groupId, op);
|
|
138
|
+
};
|
|
139
|
+
// MemberJoinedOpen on `groupId`, as a node's join sends it: a member who only
|
|
140
|
+
// inherits becomes a direct one, and a direct member's join is a no-op. The
|
|
141
|
+
// member list cannot tell those two apart (it lists inherited members too), so
|
|
142
|
+
// the join is sent and core's refusal of a direct member is the no-op.
|
|
143
|
+
// Returns whether this join made the account a member.
|
|
144
|
+
async function joinOpen(groupId) {
|
|
145
|
+
try {
|
|
146
|
+
await root(s, groupId, memberJoinedOpenOp({ member: s.account, groupId, credential: s.credential }));
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
if (isAlreadyDirectMember(e))
|
|
151
|
+
return false;
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function isMemberOfContextGroup(contextId, knownGroupId) {
|
|
156
|
+
const groupId = knownGroupId ?? String(await relay().getContextGroup(contextId));
|
|
157
|
+
const { members } = await relay().listGroupMembers(groupId);
|
|
158
|
+
return members.some((m) => m.identity.toLowerCase() === s.account.toLowerCase());
|
|
159
|
+
}
|
|
160
|
+
const writes = {
|
|
161
|
+
// The node-wide `for-application` listing is not caller-scoped, so a relay
|
|
162
|
+
// refuses it to an account. Its own scoped list, filtered, is the answer.
|
|
163
|
+
async listNamespacesForApplication(applicationId) {
|
|
164
|
+
return (await relay().listNamespaces()).filter((ns) => ns.targetApplicationId === applicationId);
|
|
165
|
+
},
|
|
166
|
+
async getNodeIdentity() {
|
|
167
|
+
return { accountId: s.account, deviceId: null, publicKey: '', deviceCertified: true };
|
|
168
|
+
},
|
|
169
|
+
async createNamespace(req) {
|
|
170
|
+
if (!app.packageName)
|
|
171
|
+
throw new NotForAccountError('createNamespace without a packageName');
|
|
172
|
+
const version = app.packageVersion ?? (await latestVersion(app.registryUrl ?? 'https://apps.calimero.network', app.packageName));
|
|
173
|
+
const { namespaceId, haEnabled, haError } = await found(s, {
|
|
174
|
+
defaultCapabilities: 231,
|
|
175
|
+
application: { applicationId: req.applicationId, package: app.packageName, version },
|
|
176
|
+
});
|
|
177
|
+
if (req.name)
|
|
178
|
+
await group(s, namespaceId, groupMetadataSetOp({ name: req.name }));
|
|
179
|
+
// A node's result is `{ namespaceId }`; HA's outcome rides along as extra
|
|
180
|
+
// fields, so code typed against the node call is unaffected.
|
|
181
|
+
return { namespaceId, haEnabled, ...(haError !== undefined ? { haError } : {}) };
|
|
182
|
+
},
|
|
183
|
+
async createGroupInNamespace(namespaceId, req = {}) {
|
|
184
|
+
// Absent ⇒ Open, as on a node: a subgroup created Restricted and flipped
|
|
185
|
+
// Open strands every namespace member outside it.
|
|
186
|
+
const { op } = await subgroupCreation({ parentId: namespaceId, restricted: req.visibility === 'restricted', admin: s.account });
|
|
187
|
+
const { groupId } = await root(s, namespaceId, op);
|
|
188
|
+
rememberGroupNamespace(s.account, groupId, namespaceId);
|
|
189
|
+
if (req.groupName)
|
|
190
|
+
await group(s, groupId, groupMetadataSetOp({ name: req.groupName }));
|
|
191
|
+
return { groupId };
|
|
192
|
+
},
|
|
193
|
+
async addGroupMembers(groupId, req) {
|
|
194
|
+
for (const m of req.members)
|
|
195
|
+
await group(s, groupId, memberAddedOp(m.identity, (m.role ?? 'Member')));
|
|
196
|
+
},
|
|
197
|
+
async removeGroupMembers(groupId, req) {
|
|
198
|
+
for (const m of req.members)
|
|
199
|
+
await group(s, groupId, memberRemovedOp(m));
|
|
200
|
+
},
|
|
201
|
+
async setGroupMetadata(groupId, req) {
|
|
202
|
+
await group(s, groupId, groupMetadataSetOp(req));
|
|
203
|
+
},
|
|
204
|
+
async setMemberMetadata(groupId, identity, req) {
|
|
205
|
+
await group(s, groupId, memberMetadataSetOp(identity, req));
|
|
206
|
+
},
|
|
207
|
+
async setContextMetadata(groupId, contextId, req) {
|
|
208
|
+
await group(s, groupId, contextMetadataSetOp(contextId, req));
|
|
209
|
+
},
|
|
210
|
+
async setDefaultCapabilities(groupId, req) {
|
|
211
|
+
await group(s, groupId, defaultCapabilitiesSetOp(req.defaultCapabilities));
|
|
212
|
+
},
|
|
213
|
+
async setSubgroupVisibility(groupId, req) {
|
|
214
|
+
await group(s, groupId, subgroupVisibilitySetOp(req.subgroupVisibility === 'open' ? 'open' : 'restricted'));
|
|
215
|
+
},
|
|
216
|
+
async setMemberCapabilities(groupId, identity, req) {
|
|
217
|
+
await group(s, groupId, memberCapabilitySetOp(identity, req.capabilities));
|
|
218
|
+
},
|
|
219
|
+
async deleteGroup(groupId) {
|
|
220
|
+
await root(s, groupId, groupDeletedOp(groupId));
|
|
221
|
+
return { isDeleted: true };
|
|
222
|
+
},
|
|
223
|
+
async reparentGroup(childGroupId, req) {
|
|
224
|
+
await root(s, childGroupId, groupReparentedOp(childGroupId, req.newParentId));
|
|
225
|
+
return { reparented: true };
|
|
226
|
+
},
|
|
227
|
+
async leaveGroup(groupId) {
|
|
228
|
+
await group(s, groupId, memberLeftOp(s.account));
|
|
229
|
+
},
|
|
230
|
+
async leaveNamespace(namespaceId) {
|
|
231
|
+
await group(s, namespaceId, memberLeftOp(s.account));
|
|
232
|
+
},
|
|
233
|
+
// An account runs as itself in every context it can reach (the relay
|
|
234
|
+
// executes as the account), so its identity there is the account. Owned
|
|
235
|
+
// when its account is a member of the context's group, as on a node a
|
|
236
|
+
// context identity is owned once the node has joined.
|
|
237
|
+
async getContextIdentitiesOwned(contextId) {
|
|
238
|
+
return { identities: (await isMemberOfContextGroup(contextId)) ? [s.account] : [] };
|
|
239
|
+
},
|
|
240
|
+
async joinContext(contextId) {
|
|
241
|
+
await joinOpen(String(await relay().getContextGroup(contextId)));
|
|
242
|
+
return { contextId, memberPublicKey: s.account };
|
|
243
|
+
},
|
|
244
|
+
async createContext(req) {
|
|
245
|
+
const info = await relay().getGroupInfo(req.groupId);
|
|
246
|
+
const namespaceId = info.namespaceId ?? req.groupId;
|
|
247
|
+
// Every field with a place in the creation warrant; `identitySecret` has
|
|
248
|
+
// none for an account (see CreateDelegatedContextRequest).
|
|
249
|
+
const { contextId } = await createContext(s, {
|
|
250
|
+
namespaceId, groupId: req.groupId, applicationId: req.applicationId,
|
|
251
|
+
serviceName: req.serviceName, contextSeed: req.contextSeed,
|
|
252
|
+
name: req.name, initializationParams: req.initializationParams,
|
|
253
|
+
});
|
|
254
|
+
// The relay executes as the account, so the account is its member key
|
|
255
|
+
// here as it is in `joinContext` and `getContextIdentitiesOwned`. Apps
|
|
256
|
+
// record this key as the context's owner; an empty one was being stored.
|
|
257
|
+
return { contextId, memberPublicKey: s.account, groupId: req.groupId };
|
|
258
|
+
},
|
|
259
|
+
async createNamespaceInvitation(namespaceId) {
|
|
260
|
+
const [{ members }, info] = await Promise.all([relay().listGroupMembers(namespaceId), relay().getGroupInfo(namespaceId)]);
|
|
261
|
+
// The relays admit: whoever claims an account's invitation may have no
|
|
262
|
+
// node, and only a relay can admit a joiner with none. With no relay in
|
|
263
|
+
// the namespace, leave it to the admins (signGroupInvitation's default).
|
|
264
|
+
const relays = members
|
|
265
|
+
.filter((m) => m.role === 'RelayTee')
|
|
266
|
+
.map((m) => m.identity.toLowerCase());
|
|
267
|
+
// Where the claimant reaches an admitter when the cloud routes them to
|
|
268
|
+
// none: set only then, so a routed namespace stays the cloud's to route.
|
|
269
|
+
let admitterAddrs;
|
|
270
|
+
if (routing) {
|
|
271
|
+
// Who the claimant's client will look for: the admitters this
|
|
272
|
+
// invitation names (the relays, else signGroupInvitation's admins).
|
|
273
|
+
const named = new Set(relays.length > 0
|
|
274
|
+
? relays
|
|
275
|
+
: members.filter((m) => m.role === 'Admin').map((m) => m.identity.toLowerCase()));
|
|
276
|
+
let nodes = null;
|
|
277
|
+
try {
|
|
278
|
+
nodes = (await routing(namespaceId)).nodes;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Nothing learned: the claimant's own lookup will say what is wrong.
|
|
282
|
+
}
|
|
283
|
+
if (nodes !== null) {
|
|
284
|
+
if (nodes.length === 0) {
|
|
285
|
+
// The cloud routes a namespace only once its founder has linked
|
|
286
|
+
// the account (HA). Unlinked, the namespace is still served by the
|
|
287
|
+
// relay that founded it and serves this session, a RelayTee
|
|
288
|
+
// member; its own `/admit` takes the join (measured on prod: 200,
|
|
289
|
+
// where the cloud's lookup answered no nodes). The claimant's
|
|
290
|
+
// client cannot find that relay through the cloud, so the
|
|
291
|
+
// invitation says where it is.
|
|
292
|
+
if (relays.length === 0 || !s.relayUrl)
|
|
293
|
+
throw new InvitationNotClaimableError(namespaceId, 'not-hosted');
|
|
294
|
+
admitterAddrs = [s.relayUrl];
|
|
295
|
+
}
|
|
296
|
+
else if (!nodes.some((n) => n.account !== null && named.has(n.account.toLowerCase()))) {
|
|
297
|
+
throw new InvitationNotClaimableError(namespaceId, 'no-named-node');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const invitation = await signInvitation({
|
|
302
|
+
groupId: namespaceId,
|
|
303
|
+
inviterAccount: s.account,
|
|
304
|
+
deviceSecret: s.deviceSecret,
|
|
305
|
+
...(relays.length > 0 ? { admitters: relays } : {}),
|
|
306
|
+
...(admitterAddrs ? { admitterAddrs } : {}),
|
|
307
|
+
members: members,
|
|
308
|
+
applicationId: info.targetApplicationId,
|
|
309
|
+
appKey: info.appKey,
|
|
310
|
+
});
|
|
311
|
+
return { invitation };
|
|
312
|
+
},
|
|
313
|
+
// The relay syncs its own view; an account has nothing to pull.
|
|
314
|
+
async syncGroup(groupId) {
|
|
315
|
+
return { groupId, synced: true };
|
|
316
|
+
},
|
|
317
|
+
async syncContext() {
|
|
318
|
+
// Same: nothing for an account to pull.
|
|
319
|
+
},
|
|
320
|
+
joinNamespace,
|
|
321
|
+
async updateMemberRole(groupId, identity, req) {
|
|
322
|
+
await group(s, groupId, memberRoleSetOp(identity, req.role));
|
|
323
|
+
},
|
|
324
|
+
// An open subgroup is joined the way `joinContext` joins one: MemberJoinedOpen
|
|
325
|
+
// on the subgroup, and nothing to do for a member already in it.
|
|
326
|
+
async joinSubgroupInheritance(groupId) {
|
|
327
|
+
return { groupId, memberPublicKey: s.account, wasInherited: await joinOpen(groupId) };
|
|
328
|
+
},
|
|
329
|
+
async detachContextFromGroup(groupId, contextId) {
|
|
330
|
+
await group(s, groupId, contextDetachedOp(contextId));
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
if (read === null) {
|
|
334
|
+
return new Proxy({}, {
|
|
335
|
+
get(_target, prop) {
|
|
336
|
+
if (typeof prop !== 'string')
|
|
337
|
+
return undefined;
|
|
338
|
+
if (prop === 'getNodeIdentity')
|
|
339
|
+
return writes.getNodeIdentity;
|
|
340
|
+
if (prop === 'joinNamespace')
|
|
341
|
+
return joinNamespace;
|
|
342
|
+
if (prop in EMPTY_READS)
|
|
343
|
+
return EMPTY_READS[prop];
|
|
344
|
+
return async () => {
|
|
345
|
+
throw new NoRelayError(prop);
|
|
346
|
+
};
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return new Proxy(read, {
|
|
351
|
+
get(target, prop, receiver) {
|
|
352
|
+
if (typeof prop === 'string' && prop in writes)
|
|
353
|
+
return writes[prop];
|
|
354
|
+
if (typeof prop === 'string' && NODE_ONLY.has(prop)) {
|
|
355
|
+
return async () => {
|
|
356
|
+
throw new NotForAccountError(prop);
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return Reflect.get(target, prop, receiver);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An application's id, computed the way merod computes it.
|
|
3
|
+
*
|
|
4
|
+
* core's `ApplicationId::for_bundle(package, signer_id)` is
|
|
5
|
+
* `sha256(borsh((package, signer_id)))`: the package name and the publisher's
|
|
6
|
+
* signing key together decide which app a bundle IS, and the version is not an
|
|
7
|
+
* input, so every version a publisher ships under one package installs as the
|
|
8
|
+
* same application. A node learns the id when it installs the bundle; an
|
|
9
|
+
* account installs nothing, so it has to derive the id from the same two
|
|
10
|
+
* inputs — both of which the registry's bundle listing carries.
|
|
11
|
+
*/
|
|
12
|
+
/** A bundle entry as `GET {registry}/api/v2/bundles?package=…` lists it. */
|
|
13
|
+
export interface RegistryBundle {
|
|
14
|
+
package?: string;
|
|
15
|
+
signerId?: string;
|
|
16
|
+
appVersion?: string;
|
|
17
|
+
yanked?: boolean;
|
|
18
|
+
publishedAt?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `sha256(borsh((pkg, signerId)))` as lowercase hex: core's
|
|
22
|
+
* `ApplicationId::for_bundle`. A borsh tuple is its fields concatenated.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applicationIdForBundle(pkg: string, signerId: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* The entry a package resolves to: its newest version that is not yanked. The
|
|
27
|
+
* one rule for "latest", shared by the version lookup and the id derivation so
|
|
28
|
+
* the two cannot disagree about which bundle an account is founding on.
|
|
29
|
+
*/
|
|
30
|
+
export declare function selectLatestBundle<B extends RegistryBundle>(bundles: readonly B[]): B | undefined;
|
|
31
|
+
/** The registry's listing of `pkg`, every version. */
|
|
32
|
+
export declare function fetchRegistryBundles(registryUrl: string, pkg: string, fetchFn?: typeof fetch): Promise<RegistryBundle[]>;
|
|
33
|
+
export interface ResolvedApplication {
|
|
34
|
+
applicationId: string;
|
|
35
|
+
signerId: string;
|
|
36
|
+
version: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The application id an account founds `pkg` on, learned from the registry.
|
|
40
|
+
*
|
|
41
|
+
* Refused when the non-yanked versions of `pkg` name more than one publisher:
|
|
42
|
+
* that is two different applications under one package name (the id binds the
|
|
43
|
+
* signer), and picking either silently would commit the founder — who signs
|
|
44
|
+
* `TargetApplicationSet` naming the id — to an app it did not choose.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveApplicationIdFromRegistry(registryUrl: string, pkg: string, deps?: {
|
|
47
|
+
fetch?: typeof fetch;
|
|
48
|
+
}): Promise<ResolvedApplication>;
|