@calimero-network/mero-js 23.8.1 → 23.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,360 @@
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
+ const { op } = await subgroupCreation({ parentId: namespaceId, restricted: req.visibility !== 'open', admin: s.account });
185
+ const { groupId } = await root(s, namespaceId, op);
186
+ rememberGroupNamespace(s.account, groupId, namespaceId);
187
+ if (req.groupName)
188
+ await group(s, groupId, groupMetadataSetOp({ name: req.groupName }));
189
+ return { groupId };
190
+ },
191
+ async addGroupMembers(groupId, req) {
192
+ for (const m of req.members)
193
+ await group(s, groupId, memberAddedOp(m.identity, (m.role ?? 'Member')));
194
+ },
195
+ async removeGroupMembers(groupId, req) {
196
+ for (const m of req.members)
197
+ await group(s, groupId, memberRemovedOp(m));
198
+ },
199
+ async setGroupMetadata(groupId, req) {
200
+ await group(s, groupId, groupMetadataSetOp(req));
201
+ },
202
+ async setMemberMetadata(groupId, identity, req) {
203
+ await group(s, groupId, memberMetadataSetOp(identity, req));
204
+ },
205
+ async setContextMetadata(groupId, contextId, req) {
206
+ await group(s, groupId, contextMetadataSetOp(contextId, req));
207
+ },
208
+ async setDefaultCapabilities(groupId, req) {
209
+ await group(s, groupId, defaultCapabilitiesSetOp(req.defaultCapabilities));
210
+ },
211
+ async setSubgroupVisibility(groupId, req) {
212
+ await group(s, groupId, subgroupVisibilitySetOp(req.subgroupVisibility === 'open' ? 'open' : 'restricted'));
213
+ },
214
+ async setMemberCapabilities(groupId, identity, req) {
215
+ await group(s, groupId, memberCapabilitySetOp(identity, req.capabilities));
216
+ },
217
+ async deleteGroup(groupId) {
218
+ await root(s, groupId, groupDeletedOp(groupId));
219
+ return { isDeleted: true };
220
+ },
221
+ async reparentGroup(childGroupId, req) {
222
+ await root(s, childGroupId, groupReparentedOp(childGroupId, req.newParentId));
223
+ return { reparented: true };
224
+ },
225
+ async leaveGroup(groupId) {
226
+ await group(s, groupId, memberLeftOp(s.account));
227
+ },
228
+ async leaveNamespace(namespaceId) {
229
+ await group(s, namespaceId, memberLeftOp(s.account));
230
+ },
231
+ // An account runs as itself in every context it can reach (the relay
232
+ // executes as the account), so its identity there is the account. Owned
233
+ // when its account is a member of the context's group, as on a node a
234
+ // context identity is owned once the node has joined.
235
+ async getContextIdentitiesOwned(contextId) {
236
+ return { identities: (await isMemberOfContextGroup(contextId)) ? [s.account] : [] };
237
+ },
238
+ async joinContext(contextId) {
239
+ await joinOpen(String(await relay().getContextGroup(contextId)));
240
+ return { contextId, memberPublicKey: s.account };
241
+ },
242
+ async createContext(req) {
243
+ const info = await relay().getGroupInfo(req.groupId);
244
+ const namespaceId = info.namespaceId ?? req.groupId;
245
+ // Every field with a place in the creation warrant; `identitySecret` has
246
+ // none for an account (see CreateDelegatedContextRequest).
247
+ const { contextId } = await createContext(s, {
248
+ namespaceId, groupId: req.groupId, applicationId: req.applicationId,
249
+ serviceName: req.serviceName, contextSeed: req.contextSeed,
250
+ name: req.name, initializationParams: req.initializationParams,
251
+ });
252
+ // The relay executes as the account, so the account is its member key
253
+ // here as it is in `joinContext` and `getContextIdentitiesOwned`. Apps
254
+ // record this key as the context's owner; an empty one was being stored.
255
+ return { contextId, memberPublicKey: s.account, groupId: req.groupId };
256
+ },
257
+ async createNamespaceInvitation(namespaceId) {
258
+ const [{ members }, info] = await Promise.all([relay().listGroupMembers(namespaceId), relay().getGroupInfo(namespaceId)]);
259
+ // The relays admit: whoever claims an account's invitation may have no
260
+ // node, and only a relay can admit a joiner with none. With no relay in
261
+ // the namespace, leave it to the admins (signGroupInvitation's default).
262
+ const relays = members
263
+ .filter((m) => m.role === 'RelayTee')
264
+ .map((m) => m.identity.toLowerCase());
265
+ // Where the claimant reaches an admitter when the cloud routes them to
266
+ // none: set only then, so a routed namespace stays the cloud's to route.
267
+ let admitterAddrs;
268
+ if (routing) {
269
+ // Who the claimant's client will look for: the admitters this
270
+ // invitation names (the relays, else signGroupInvitation's admins).
271
+ const named = new Set(relays.length > 0
272
+ ? relays
273
+ : members.filter((m) => m.role === 'Admin').map((m) => m.identity.toLowerCase()));
274
+ let nodes = null;
275
+ try {
276
+ nodes = (await routing(namespaceId)).nodes;
277
+ }
278
+ catch {
279
+ // Nothing learned: the claimant's own lookup will say what is wrong.
280
+ }
281
+ if (nodes !== null) {
282
+ if (nodes.length === 0) {
283
+ // The cloud routes a namespace only once its founder has linked
284
+ // the account (HA). Unlinked, the namespace is still served by the
285
+ // relay that founded it and serves this session, a RelayTee
286
+ // member; its own `/admit` takes the join (measured on prod: 200,
287
+ // where the cloud's lookup answered no nodes). The claimant's
288
+ // client cannot find that relay through the cloud, so the
289
+ // invitation says where it is.
290
+ if (relays.length === 0 || !s.relayUrl)
291
+ throw new InvitationNotClaimableError(namespaceId, 'not-hosted');
292
+ admitterAddrs = [s.relayUrl];
293
+ }
294
+ else if (!nodes.some((n) => n.account !== null && named.has(n.account.toLowerCase()))) {
295
+ throw new InvitationNotClaimableError(namespaceId, 'no-named-node');
296
+ }
297
+ }
298
+ }
299
+ const invitation = await signInvitation({
300
+ groupId: namespaceId,
301
+ inviterAccount: s.account,
302
+ deviceSecret: s.deviceSecret,
303
+ ...(relays.length > 0 ? { admitters: relays } : {}),
304
+ ...(admitterAddrs ? { admitterAddrs } : {}),
305
+ members: members,
306
+ applicationId: info.targetApplicationId,
307
+ appKey: info.appKey,
308
+ });
309
+ return { invitation };
310
+ },
311
+ // The relay syncs its own view; an account has nothing to pull.
312
+ async syncGroup(groupId) {
313
+ return { groupId, synced: true };
314
+ },
315
+ async syncContext() {
316
+ // Same: nothing for an account to pull.
317
+ },
318
+ joinNamespace,
319
+ async updateMemberRole(groupId, identity, req) {
320
+ await group(s, groupId, memberRoleSetOp(identity, req.role));
321
+ },
322
+ // An open subgroup is joined the way `joinContext` joins one: MemberJoinedOpen
323
+ // on the subgroup, and nothing to do for a member already in it.
324
+ async joinSubgroupInheritance(groupId) {
325
+ return { groupId, memberPublicKey: s.account, wasInherited: await joinOpen(groupId) };
326
+ },
327
+ async detachContextFromGroup(groupId, contextId) {
328
+ await group(s, groupId, contextDetachedOp(contextId));
329
+ },
330
+ };
331
+ if (read === null) {
332
+ return new Proxy({}, {
333
+ get(_target, prop) {
334
+ if (typeof prop !== 'string')
335
+ return undefined;
336
+ if (prop === 'getNodeIdentity')
337
+ return writes.getNodeIdentity;
338
+ if (prop === 'joinNamespace')
339
+ return joinNamespace;
340
+ if (prop in EMPTY_READS)
341
+ return EMPTY_READS[prop];
342
+ return async () => {
343
+ throw new NoRelayError(prop);
344
+ };
345
+ },
346
+ });
347
+ }
348
+ return new Proxy(read, {
349
+ get(target, prop, receiver) {
350
+ if (typeof prop === 'string' && prop in writes)
351
+ return writes[prop];
352
+ if (typeof prop === 'string' && NODE_ONLY.has(prop)) {
353
+ return async () => {
354
+ throw new NotForAccountError(prop);
355
+ };
356
+ }
357
+ return Reflect.get(target, prop, receiver);
358
+ },
359
+ });
360
+ }
@@ -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>;