@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 +95 -0
- package/dist/account/account-admin.d.ts +82 -0
- package/dist/account/account-admin.js +360 -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/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +4302 -2970
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/index.mjs +4292 -2970
- package/dist/index.mjs.map +4 -4
- package/dist/namespace-op/namespace-op.d.ts +6 -1
- package/dist/namespace-op/namespace-op.js +6 -1
- package/package.json +6 -3
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { delegatedGovernance } from './create-context.js';
|
|
2
|
+
import { readRelayMap } from './session.js';
|
|
3
|
+
const GROUP_NAMESPACE_PREFIX = 'calimero.delegated.group-namespace.';
|
|
4
|
+
// What this page has learned, whether or not storage keeps it: a private window
|
|
5
|
+
// or blocked site data must not turn a known subgroup back into an unknown one.
|
|
6
|
+
const learned = new Map();
|
|
7
|
+
function readGroupNamespaces(account) {
|
|
8
|
+
let stored = {};
|
|
9
|
+
try {
|
|
10
|
+
stored = JSON.parse(localStorage.getItem(GROUP_NAMESPACE_PREFIX + account) ?? '{}');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
/* no storage: what this page learned still stands */
|
|
14
|
+
}
|
|
15
|
+
return { ...stored, ...learned.get(account) };
|
|
16
|
+
}
|
|
17
|
+
/** Record that `groupId` is a subgroup of `namespaceId`, for this account. */
|
|
18
|
+
export function rememberGroupNamespace(account, groupId, namespaceId) {
|
|
19
|
+
learned.set(account, { ...learned.get(account), [groupId]: namespaceId });
|
|
20
|
+
try {
|
|
21
|
+
const all = readGroupNamespaces(account);
|
|
22
|
+
all[groupId] = namespaceId;
|
|
23
|
+
localStorage.setItem(GROUP_NAMESPACE_PREFIX + account, JSON.stringify(all));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* unpersisted: root ops on this subgroup will ask for its namespace */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The namespace a group belongs to: itself if it is a namespace this account is in, else what was remembered. */
|
|
30
|
+
export function namespaceOfGroup(s, groupId) {
|
|
31
|
+
if (readRelayMap(s.account).namespaces[groupId])
|
|
32
|
+
return groupId;
|
|
33
|
+
return readGroupNamespaces(s.account)[groupId];
|
|
34
|
+
}
|
|
35
|
+
function requireNamespace(s, groupId) {
|
|
36
|
+
const ns = namespaceOfGroup(s, groupId);
|
|
37
|
+
if (!ns) {
|
|
38
|
+
throw new Error(`the namespace of group ${groupId} is not known to this account: it can manage only groups in namespaces it joined or subgroups it created`);
|
|
39
|
+
}
|
|
40
|
+
return ns;
|
|
41
|
+
}
|
|
42
|
+
/** A group op (member, role, capabilities, metadata, visibility, detach) on `groupId`. */
|
|
43
|
+
export async function governGroup(s, groupId, op) {
|
|
44
|
+
return delegatedGovernance(s, { namespaceId: requireNamespace(s, groupId), group: groupId, op });
|
|
45
|
+
}
|
|
46
|
+
/** A root op, posted to the namespace `groupId` belongs to. */
|
|
47
|
+
export async function governRoot(s, groupId, op) {
|
|
48
|
+
const namespaceId = requireNamespace(s, groupId);
|
|
49
|
+
return delegatedGovernance(s, { namespaceId, group: namespaceId, op });
|
|
50
|
+
}
|
package/dist/account/index.d.ts
CHANGED
|
@@ -4,3 +4,21 @@ export { resolveRoot } from './account.js';
|
|
|
4
4
|
export type { RootSource } from './account.js';
|
|
5
5
|
export { resolveRootPair } from './account.js';
|
|
6
6
|
export type { ResolvedRoot } from './account.js';
|
|
7
|
+
export type { DelegatedCredential, DelegatedSession as DelegatedAccountSession, RelayMap, RelayNodeKeyAttempt } from './session.js';
|
|
8
|
+
export { readDelegatedSession, saveDelegatedSession, clearDelegatedSession, readDelegatedCredential, saveDelegatedCredential, clearDelegatedCredential, persistedNonces, markContextNonceSpent, pinRelayNodeKey, readPinnedRelayNodeKey, attemptRelayNodeKey, resolveRelayNodeKey, learnRelayNodeKey, RELAY_NODE_KEY_RETRY_MS, forgetMethodKinds, buildDelegatedClient, readRelayMap, rememberRelay, relayForContext, carryExecutorAccount, knownRelays, listDelegatedContexts, listDelegatedNamespaces, } from './session.js';
|
|
9
|
+
export { resolveRelayFromInvitation, normaliseAccount } from './relay-from-invitation.js';
|
|
10
|
+
export type { RelayResolutionStep, RelayResolutionFailure, ResolvedInvitationRelay, ResolveRelayResult, ResolveRelayFromInvitationInput, } from './relay-from-invitation.js';
|
|
11
|
+
export { joinWithNode } from './join-with-node.js';
|
|
12
|
+
export type { JoinWithNodeInput, JoinWithNodeResult, JoinWithNodeSuccess, JoinWithNodeFailure, JoinWithNodeStep, } from './join-with-node.js';
|
|
13
|
+
export { bootstrapFromInvitation } from './bootstrap-from-invitation.js';
|
|
14
|
+
export type { BootstrapStep, BootstrapResult, BootstrapSuccess, BootstrapFailure, BootstrapFromInvitationInput, } from './bootstrap-from-invitation.js';
|
|
15
|
+
export { joinAsAccount } from './join-as-account.js';
|
|
16
|
+
export { createDelegatedContext, createDelegatedPrivateContext, foundDelegatedNamespace, delegatedGovernance, latestPublishedVersion, HA_ACCOUNT_NOT_LINKED_MESSAGE, HA_REFUSAL_MESSAGES, } from './create-context.js';
|
|
17
|
+
export type { CreateDelegatedContextRequest, FoundedDelegatedNamespace } from './create-context.js';
|
|
18
|
+
export { governGroup, governRoot, namespaceOfGroup, rememberGroupNamespace } from './govern.js';
|
|
19
|
+
export { applicationIdForBundle, fetchRegistryBundles, resolveApplicationIdFromRegistry, selectLatestBundle, } from './application-id.js';
|
|
20
|
+
export type { RegistryBundle, ResolvedApplication } from './application-id.js';
|
|
21
|
+
export { createAccountAdmin, InvitationNotClaimableError, NoRelayError, NotForAccountError, } from './account-admin.js';
|
|
22
|
+
export type { AccountAdminDeps } from './account-admin.js';
|
|
23
|
+
export { createNodeAdmin } from './node-admin.js';
|
|
24
|
+
export type { NodeAdminDeps } from './node-admin.js';
|
package/dist/account/index.js
CHANGED
|
@@ -2,3 +2,13 @@
|
|
|
2
2
|
export { generateAccountRoot, accountRootFromPhrase, accountRootFromSecret, createAccountRootSigner, accountRootSignerFromPhrase, signAccountLink, signAccountLogin, } from './account.js';
|
|
3
3
|
export { resolveRoot } from './account.js';
|
|
4
4
|
export { resolveRootPair } from './account.js';
|
|
5
|
+
export { readDelegatedSession, saveDelegatedSession, clearDelegatedSession, readDelegatedCredential, saveDelegatedCredential, clearDelegatedCredential, persistedNonces, markContextNonceSpent, pinRelayNodeKey, readPinnedRelayNodeKey, attemptRelayNodeKey, resolveRelayNodeKey, learnRelayNodeKey, RELAY_NODE_KEY_RETRY_MS, forgetMethodKinds, buildDelegatedClient, readRelayMap, rememberRelay, relayForContext, carryExecutorAccount, knownRelays, listDelegatedContexts, listDelegatedNamespaces, } from './session.js';
|
|
6
|
+
export { resolveRelayFromInvitation, normaliseAccount } from './relay-from-invitation.js';
|
|
7
|
+
export { joinWithNode } from './join-with-node.js';
|
|
8
|
+
export { bootstrapFromInvitation } from './bootstrap-from-invitation.js';
|
|
9
|
+
export { joinAsAccount } from './join-as-account.js';
|
|
10
|
+
export { createDelegatedContext, createDelegatedPrivateContext, foundDelegatedNamespace, delegatedGovernance, latestPublishedVersion, HA_ACCOUNT_NOT_LINKED_MESSAGE, HA_REFUSAL_MESSAGES, } from './create-context.js';
|
|
11
|
+
export { governGroup, governRoot, namespaceOfGroup, rememberGroupNamespace } from './govern.js';
|
|
12
|
+
export { applicationIdForBundle, fetchRegistryBundles, resolveApplicationIdFromRegistry, selectLatestBundle, } from './application-id.js';
|
|
13
|
+
export { createAccountAdmin, InvitationNotClaimableError, NoRelayError, NotForAccountError, } from './account-admin.js';
|
|
14
|
+
export { createNodeAdmin } from './node-admin.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SignedGroupOpenInvitation } from '../admin-api/index.js';
|
|
2
|
+
import { bootstrapFromInvitation } from './bootstrap-from-invitation.js';
|
|
3
|
+
import { type DelegatedSession } from './session.js';
|
|
4
|
+
/**
|
|
5
|
+
* Redeem an invitation for an account, the way `useDelegatedBootstrap` does:
|
|
6
|
+
* resolve the relay the invitation names, have it admit the account, add that
|
|
7
|
+
* relay to the account's map and hand the session over to `onJoined`.
|
|
8
|
+
*
|
|
9
|
+
* It is what an account's `admin.joinNamespace` runs, so an app that joins with
|
|
10
|
+
* the node call gets the account path with no code of its own. It works with no
|
|
11
|
+
* relay yet: the join is how an account gets its first one.
|
|
12
|
+
*
|
|
13
|
+
* Rejects on a refused join with the refusal's reason, its `step`, and its HTTP
|
|
14
|
+
* `status` when it has one, as the node call would; nothing is remembered or
|
|
15
|
+
* switched then.
|
|
16
|
+
*/
|
|
17
|
+
export declare function joinAsAccount(session: DelegatedSession, namespaceId: string, invitation: SignedGroupOpenInvitation, opts: {
|
|
18
|
+
cloudBaseUrl?: string;
|
|
19
|
+
onJoined: (session: DelegatedSession) => void;
|
|
20
|
+
bootstrap?: typeof bootstrapFromInvitation;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
namespaceId: string;
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { bootstrapFromInvitation } from './bootstrap-from-invitation.js';
|
|
2
|
+
import { carryExecutorAccount, rememberRelay } from './session.js';
|
|
3
|
+
/**
|
|
4
|
+
* Redeem an invitation for an account, the way `useDelegatedBootstrap` does:
|
|
5
|
+
* resolve the relay the invitation names, have it admit the account, add that
|
|
6
|
+
* relay to the account's map and hand the session over to `onJoined`.
|
|
7
|
+
*
|
|
8
|
+
* It is what an account's `admin.joinNamespace` runs, so an app that joins with
|
|
9
|
+
* the node call gets the account path with no code of its own. It works with no
|
|
10
|
+
* relay yet: the join is how an account gets its first one.
|
|
11
|
+
*
|
|
12
|
+
* Rejects on a refused join with the refusal's reason, its `step`, and its HTTP
|
|
13
|
+
* `status` when it has one, as the node call would; nothing is remembered or
|
|
14
|
+
* switched then.
|
|
15
|
+
*/
|
|
16
|
+
export async function joinAsAccount(session, namespaceId, invitation, opts) {
|
|
17
|
+
const bootstrap = opts.bootstrap ?? bootstrapFromInvitation;
|
|
18
|
+
const outcome = await bootstrap({
|
|
19
|
+
namespaceId,
|
|
20
|
+
invitation,
|
|
21
|
+
credential: { account: session.account, credential: session.credential, deviceSecret: session.deviceSecret },
|
|
22
|
+
cloudBaseUrl: opts.cloudBaseUrl,
|
|
23
|
+
});
|
|
24
|
+
if (outcome.ok === false) {
|
|
25
|
+
throw Object.assign(new Error(outcome.reason), {
|
|
26
|
+
step: outcome.step,
|
|
27
|
+
...(outcome.status !== undefined ? { status: outcome.status } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (outcome.session.relayUrl)
|
|
31
|
+
rememberRelay(session.account, outcome.session.relayUrl, { namespaceId });
|
|
32
|
+
opts.onJoined(carryExecutorAccount(session, outcome.session));
|
|
33
|
+
return { namespaceId };
|
|
34
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Present a signed join to ONE named node.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole of admission for a caller with no node of its own: sign the
|
|
5
|
+
* membership op with the device key the account certified, and POST it to a node
|
|
6
|
+
* the invitation named as an admitter.
|
|
7
|
+
*
|
|
8
|
+
* POST /admin-api/namespaces/{namespaceId}/admit
|
|
9
|
+
* { "invitation": SignedGroupOpenInvitation, "signedOp": "<hex borsh SignedNamespaceOp>" }
|
|
10
|
+
*
|
|
11
|
+
* Verified against core at rc.43 — `crates/server/src/admin/handlers/namespaces/
|
|
12
|
+
* admit_join.rs`, request type `AdmitJoinApiRequest` in
|
|
13
|
+
* `crates/server/primitives/src/admin/mod.rs`. The handler also refuses a
|
|
14
|
+
* mismatch between the invitation's `group_id` and the namespace in the path, so
|
|
15
|
+
* the two are never allowed to disagree here.
|
|
16
|
+
*
|
|
17
|
+
* ## Why it needs no token and no proof
|
|
18
|
+
*
|
|
19
|
+
* The joiner's signature IS the authorization. The op is signed by the device
|
|
20
|
+
* key named in the credential it carries, and every peer checks
|
|
21
|
+
* `signer == credential.statement.sign_pk` before applying a join — so an
|
|
22
|
+
* admitter can carry this and can never author it: it cannot admit a different
|
|
23
|
+
* account, change the group, or grant a role. It can refuse, and that is all.
|
|
24
|
+
* This is also why the fleet ingress leaves the route unauthenticated, and why
|
|
25
|
+
* this posts with `fetch` rather than through `AdminApiClient`, which is built
|
|
26
|
+
* around a node credential this caller does not have.
|
|
27
|
+
*
|
|
28
|
+
* ## Why it lives here and not in mero-js
|
|
29
|
+
*
|
|
30
|
+
* Two reasons. `mero-js` is read-only for this change by instruction, and the
|
|
31
|
+
* shape that is actually useful to a caller is the one that hands back a
|
|
32
|
+
* {@link DelegatedSession} — the node that admitted you is the node to connect
|
|
33
|
+
* through — which is `mero-react`'s concern. The pieces that are pure SDK, the
|
|
34
|
+
* borsh op layout and the request type, are used from `mero-js` unchanged and
|
|
35
|
+
* are not reimplemented here.
|
|
36
|
+
*
|
|
37
|
+
* ## Why the op has no parents
|
|
38
|
+
*
|
|
39
|
+
* A keyholder holds no node, so it has no view of the namespace DAG and cannot
|
|
40
|
+
* name its heads. Empty parents is the only thing it *can* sign, and the direct
|
|
41
|
+
* admission path exists precisely for callers in that position.
|
|
42
|
+
*/
|
|
43
|
+
import { type SignedGroupOpenInvitation } from '../admin-api/index.js';
|
|
44
|
+
import type { DelegatedCredential, DelegatedSession } from './session.js';
|
|
45
|
+
/** Which step failed, when one did. */
|
|
46
|
+
export type JoinWithNodeStep =
|
|
47
|
+
/** The op could not be signed. Local, deterministic, and nothing was sent. */
|
|
48
|
+
'sign'
|
|
49
|
+
/** The node refused it, or could not be reached. */
|
|
50
|
+
| 'admit';
|
|
51
|
+
export interface JoinWithNodeSuccess {
|
|
52
|
+
readonly ok: true;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the admitter said it published the op.
|
|
55
|
+
*
|
|
56
|
+
* NOT "you are a member": membership lands when peers apply the op, which the
|
|
57
|
+
* admitter neither performs nor waits for.
|
|
58
|
+
*/
|
|
59
|
+
readonly published: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The session to connect with — the admitting node, used immediately.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately not waiting for `getAccountRelays` to answer: the fleet writes
|
|
64
|
+
* the account's recovery envelope asynchronously, so the cloud lookup lags the
|
|
65
|
+
* admission by an unpredictable amount. The node that just carried the join is
|
|
66
|
+
* known to serve this namespace right now, which is a better answer than a
|
|
67
|
+
* lookup that will agree later.
|
|
68
|
+
*/
|
|
69
|
+
readonly session: DelegatedSession;
|
|
70
|
+
}
|
|
71
|
+
export interface JoinWithNodeFailure {
|
|
72
|
+
readonly ok: false;
|
|
73
|
+
readonly step: JoinWithNodeStep;
|
|
74
|
+
readonly reason: string;
|
|
75
|
+
/** The HTTP status, when the node answered with one. */
|
|
76
|
+
readonly status?: number;
|
|
77
|
+
}
|
|
78
|
+
export type JoinWithNodeResult = JoinWithNodeSuccess | JoinWithNodeFailure;
|
|
79
|
+
export interface JoinWithNodeInput {
|
|
80
|
+
/**
|
|
81
|
+
* The node to present the join to — its admin API origin.
|
|
82
|
+
*
|
|
83
|
+
* The primary parameter, so all three sources of a node URL feed one method:
|
|
84
|
+
* one carried by the invitation (core emits only libp2p multiaddrs today —
|
|
85
|
+
* see `resolve_admitter_addrs`, a node cannot know the URL its admin API is
|
|
86
|
+
* proxied on — so this is the seam for when invitations carry a claim URL),
|
|
87
|
+
* one resolved from the cloud's `/admitters` lookup, and one an operator
|
|
88
|
+
* simply knows.
|
|
89
|
+
*/
|
|
90
|
+
readonly nodeUrl: string;
|
|
91
|
+
/**
|
|
92
|
+
* The exact URL to POST to, when something already knows it.
|
|
93
|
+
*
|
|
94
|
+
* The cloud hands back a ready-made `admit_url` which need not be a path on
|
|
95
|
+
* `nodeUrl`'s origin, and rebuilding it from a guess is how a working
|
|
96
|
+
* admission becomes a 404. Omitted, the documented route on `nodeUrl` is used.
|
|
97
|
+
*/
|
|
98
|
+
readonly admitUrl?: string;
|
|
99
|
+
/** The namespace being joined, 64 hex. Must match the invitation's group. */
|
|
100
|
+
readonly namespaceId: string;
|
|
101
|
+
/**
|
|
102
|
+
* The invitation, exactly as the inviter's node returned it.
|
|
103
|
+
*
|
|
104
|
+
* Passed through unchanged and never rebuilt field by field: the signature
|
|
105
|
+
* covers the body, and re-modelling it through a local type drops the unsigned
|
|
106
|
+
* bootstrap fields and invalidates the signature with them.
|
|
107
|
+
*/
|
|
108
|
+
readonly invitation: SignedGroupOpenInvitation;
|
|
109
|
+
/** The enrolled account, its certificate, and the device secret that signs. */
|
|
110
|
+
readonly credential: DelegatedCredential;
|
|
111
|
+
/** Injected in tests. */
|
|
112
|
+
readonly deps?: {
|
|
113
|
+
readonly fetch?: typeof fetch;
|
|
114
|
+
readonly nonce?: () => Promise<bigint>;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Sign the membership op and present it to `nodeUrl`.
|
|
119
|
+
*
|
|
120
|
+
* Resolves rather than throws for every expected refusal, because they are
|
|
121
|
+
* different things for the person to do: a malformed op is final, an unreachable
|
|
122
|
+
* node is a retry, and a 409 means pick another admitter.
|
|
123
|
+
*/
|
|
124
|
+
export declare function joinWithNode(input: JoinWithNodeInput): Promise<JoinWithNodeResult>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Present a signed join to ONE named node.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole of admission for a caller with no node of its own: sign the
|
|
5
|
+
* membership op with the device key the account certified, and POST it to a node
|
|
6
|
+
* the invitation named as an admitter.
|
|
7
|
+
*
|
|
8
|
+
* POST /admin-api/namespaces/{namespaceId}/admit
|
|
9
|
+
* { "invitation": SignedGroupOpenInvitation, "signedOp": "<hex borsh SignedNamespaceOp>" }
|
|
10
|
+
*
|
|
11
|
+
* Verified against core at rc.43 — `crates/server/src/admin/handlers/namespaces/
|
|
12
|
+
* admit_join.rs`, request type `AdmitJoinApiRequest` in
|
|
13
|
+
* `crates/server/primitives/src/admin/mod.rs`. The handler also refuses a
|
|
14
|
+
* mismatch between the invitation's `group_id` and the namespace in the path, so
|
|
15
|
+
* the two are never allowed to disagree here.
|
|
16
|
+
*
|
|
17
|
+
* ## Why it needs no token and no proof
|
|
18
|
+
*
|
|
19
|
+
* The joiner's signature IS the authorization. The op is signed by the device
|
|
20
|
+
* key named in the credential it carries, and every peer checks
|
|
21
|
+
* `signer == credential.statement.sign_pk` before applying a join — so an
|
|
22
|
+
* admitter can carry this and can never author it: it cannot admit a different
|
|
23
|
+
* account, change the group, or grant a role. It can refuse, and that is all.
|
|
24
|
+
* This is also why the fleet ingress leaves the route unauthenticated, and why
|
|
25
|
+
* this posts with `fetch` rather than through `AdminApiClient`, which is built
|
|
26
|
+
* around a node credential this caller does not have.
|
|
27
|
+
*
|
|
28
|
+
* ## Why it lives here and not in mero-js
|
|
29
|
+
*
|
|
30
|
+
* Two reasons. `mero-js` is read-only for this change by instruction, and the
|
|
31
|
+
* shape that is actually useful to a caller is the one that hands back a
|
|
32
|
+
* {@link DelegatedSession} — the node that admitted you is the node to connect
|
|
33
|
+
* through — which is `mero-react`'s concern. The pieces that are pure SDK, the
|
|
34
|
+
* borsh op layout and the request type, are used from `mero-js` unchanged and
|
|
35
|
+
* are not reimplemented here.
|
|
36
|
+
*
|
|
37
|
+
* ## Why the op has no parents
|
|
38
|
+
*
|
|
39
|
+
* A keyholder holds no node, so it has no view of the namespace DAG and cannot
|
|
40
|
+
* name its heads. Empty parents is the only thing it *can* sign, and the direct
|
|
41
|
+
* admission path exists precisely for callers in that position.
|
|
42
|
+
*/
|
|
43
|
+
import { createLocalStorageNonceSource, } from '../relay/index.js';
|
|
44
|
+
import { signMemberJoinOp, } from '../namespace-op/index.js';
|
|
45
|
+
/**
|
|
46
|
+
* Turn an admit refusal into the thing to go and do about it.
|
|
47
|
+
*
|
|
48
|
+
* Each status has one dominant cause and they have nothing to do with each
|
|
49
|
+
* other, so a bare "HTTP 403" sends people to the wrong place — most often to
|
|
50
|
+
* the invitation, when the real answer is which node it was presented to.
|
|
51
|
+
*/
|
|
52
|
+
function explainAdmitFailure(status, body) {
|
|
53
|
+
const detail = body ? `: ${body}` : '';
|
|
54
|
+
switch (status) {
|
|
55
|
+
case 400:
|
|
56
|
+
return (`The node refused the join as malformed (400)${detail}. The signature covers the ` +
|
|
57
|
+
'invitation exactly as sent, so an edited or re-serialised invitation fails here — as ' +
|
|
58
|
+
"does a namespace id that is not the invitation's own group.");
|
|
59
|
+
case 403:
|
|
60
|
+
return (`The node refused to carry this join (403)${detail}. Either it is not in the ` +
|
|
61
|
+
"invitation's signed admitters list — being live and listed by the cloud is not the " +
|
|
62
|
+
'same thing — or the invitation itself was rejected as expired or not the inviter’s ' +
|
|
63
|
+
'to issue.');
|
|
64
|
+
case 409:
|
|
65
|
+
return (`That node holds no device of its own, so it cannot endorse anyone (409)${detail}. ` +
|
|
66
|
+
'Another admitter from the same invitation would work.');
|
|
67
|
+
default:
|
|
68
|
+
return `The join was not published (HTTP ${status})${detail}.`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A join-nonce counter for this account, persisted so a reload does not replay.
|
|
73
|
+
*
|
|
74
|
+
* The device secret never appears in the key: storage keys are enumerable by
|
|
75
|
+
* anything running on the origin. One counter per account is right here — a
|
|
76
|
+
* bootstrap is once per account per device, and the node's receiving window
|
|
77
|
+
* accepts gaps, so a shared counter can only ever skip, never reuse.
|
|
78
|
+
*/
|
|
79
|
+
function joinNonce(account) {
|
|
80
|
+
const source = createLocalStorageNonceSource(`calimero.delegated.joinnonce.${account}`);
|
|
81
|
+
return () => source.next();
|
|
82
|
+
}
|
|
83
|
+
/** Strip a trailing slash so a pasted URL and a typed one address one node. */
|
|
84
|
+
function origin(url) {
|
|
85
|
+
return url.replace(/\/+$/, '');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Sign the membership op and present it to `nodeUrl`.
|
|
89
|
+
*
|
|
90
|
+
* Resolves rather than throws for every expected refusal, because they are
|
|
91
|
+
* different things for the person to do: a malformed op is final, an unreachable
|
|
92
|
+
* node is a retry, and a 409 means pick another admitter.
|
|
93
|
+
*/
|
|
94
|
+
export async function joinWithNode(input) {
|
|
95
|
+
const { account, credential, deviceSecret } = input.credential;
|
|
96
|
+
let signedOp;
|
|
97
|
+
try {
|
|
98
|
+
signedOp = await signMemberJoinOp({
|
|
99
|
+
namespaceId: input.namespaceId,
|
|
100
|
+
member: account,
|
|
101
|
+
invitation: input.invitation,
|
|
102
|
+
credential,
|
|
103
|
+
deviceSecret,
|
|
104
|
+
nonce: await (input.deps?.nonce ?? joinNonce(account))(),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
step: 'sign',
|
|
111
|
+
reason: 'The join op could not be signed, so nothing was sent: ' +
|
|
112
|
+
`${e instanceof Error ? e.message : String(e)}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// `AdmitJoinRequest` names the shape, so a change to it in mero-js breaks this
|
|
116
|
+
// at the typecheck rather than as a 400 from a node.
|
|
117
|
+
const body = { invitation: input.invitation, signedOp };
|
|
118
|
+
const url = input.admitUrl ??
|
|
119
|
+
`${origin(input.nodeUrl)}/admin-api/namespaces/${input.namespaceId}/admit`;
|
|
120
|
+
const doFetch = input.deps?.fetch ?? fetch;
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
response = await doFetch(url, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
// No Authorization header and no request proof, deliberately: the route is
|
|
126
|
+
// unauthenticated because the joiner's signature is the authorization.
|
|
127
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
128
|
+
body: JSON.stringify(body),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
step: 'admit',
|
|
135
|
+
reason: `The admitter at ${input.nodeUrl} could not be reached: ` +
|
|
136
|
+
`${e instanceof Error ? e.message : String(e)}. The invitation is untouched — this is ` +
|
|
137
|
+
'the node being unreachable, not a refusal.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const text = await response.text();
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
step: 'admit',
|
|
145
|
+
status: response.status,
|
|
146
|
+
reason: explainAdmitFailure(response.status, text),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
let published = false;
|
|
150
|
+
try {
|
|
151
|
+
const parsed = text ? JSON.parse(text) : {};
|
|
152
|
+
published = parsed.data?.published === true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// A 2xx with a body this client cannot read is still a node that took the
|
|
156
|
+
// call. Reported as "not published" rather than as an error, because that is
|
|
157
|
+
// exactly what is not known.
|
|
158
|
+
published = false;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
published,
|
|
163
|
+
session: { relayUrl: origin(input.nodeUrl), account, credential, deviceSecret },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node's admin API, as an app writes against it: mero-js's `AdminApiClient`
|
|
3
|
+
* with `createNamespace` made whole. On a node a namespace can only be created
|
|
4
|
+
* for an application the node already holds, so this installs the provider's
|
|
5
|
+
* package from the registry first when it is missing — the step an account
|
|
6
|
+
* never sees, because founding through a relay names the package and the relay
|
|
7
|
+
* fetches it. Same call, same result, on both.
|
|
8
|
+
*/
|
|
9
|
+
import type { AdminApiClient } from '../admin-api/index.js';
|
|
10
|
+
export interface NodeAdminDeps {
|
|
11
|
+
latestVersion?: (registryUrl: string, pkg: string) => Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
export declare function createNodeAdmin(input: {
|
|
14
|
+
admin: AdminApiClient;
|
|
15
|
+
app: {
|
|
16
|
+
packageName?: string;
|
|
17
|
+
packageVersion?: string;
|
|
18
|
+
registryUrl?: string;
|
|
19
|
+
};
|
|
20
|
+
}, deps?: NodeAdminDeps): AdminApiClient;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { latestPublishedVersion } from './create-context.js';
|
|
2
|
+
export function createNodeAdmin(input, deps = {}) {
|
|
3
|
+
const { admin, app } = input;
|
|
4
|
+
const latestVersion = deps.latestVersion ?? latestPublishedVersion;
|
|
5
|
+
async function ensureInstalled(applicationId) {
|
|
6
|
+
const { apps } = await admin.listApplications();
|
|
7
|
+
if (apps.some((a) => a.id === applicationId))
|
|
8
|
+
return;
|
|
9
|
+
if (!app.packageName) {
|
|
10
|
+
throw new Error(`application ${applicationId} is not installed on this node, and no packageName is set to install it`);
|
|
11
|
+
}
|
|
12
|
+
const version = app.packageVersion ?? (await latestVersion(app.registryUrl ?? 'https://apps.calimero.network', app.packageName));
|
|
13
|
+
const { applicationId: installed } = await admin.installApplication({ package: app.packageName, version });
|
|
14
|
+
// A registry bundle signed by a different key is a different application:
|
|
15
|
+
// creating against it would found a workspace for some other app.
|
|
16
|
+
if (installed !== applicationId) {
|
|
17
|
+
throw new Error(`installed ${app.packageName}@${version} as ${installed}, not ${applicationId}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const overrides = {
|
|
21
|
+
async createNamespace(req) {
|
|
22
|
+
await ensureInstalled(req.applicationId);
|
|
23
|
+
return admin.createNamespace(req);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
return new Proxy(admin, {
|
|
27
|
+
get(target, prop, receiver) {
|
|
28
|
+
if (typeof prop === 'string' && prop in overrides)
|
|
29
|
+
return overrides[prop];
|
|
30
|
+
return Reflect.get(target, prop, receiver);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a relay from an INVITATION rather than from an account.
|
|
3
|
+
*
|
|
4
|
+
* # The gap this closes
|
|
5
|
+
*
|
|
6
|
+
* `ConnectButtonAccount` finds a relay by asking the cloud what serves the
|
|
7
|
+
* *account* (`CloudClient.getAccountRelays`). A freshly enrolled account is a
|
|
8
|
+
* member of nothing, so that read correctly answers `[]` and the button
|
|
9
|
+
* correctly refuses to connect: there is nowhere to write, and a typed guess
|
|
10
|
+
* would be a node that refuses every warrant.
|
|
11
|
+
*
|
|
12
|
+
* But a newcomer is not stuck — they are holding an invitation, and an
|
|
13
|
+
* invitation names a namespace. `CloudClient.getNamespaceRouting` answers which
|
|
14
|
+
* nodes serve *that namespace*, and it is deliberately anonymous: a joiner has
|
|
15
|
+
* no cloud session, and needing one is exactly what this path exists to avoid.
|
|
16
|
+
* So the bootstrap order is namespace first, account second — admission is what
|
|
17
|
+
* makes `getAccountRelays` answer at all, not the other way round.
|
|
18
|
+
*
|
|
19
|
+
* # Two sources, one intersection, and why it must not be skipped
|
|
20
|
+
*
|
|
21
|
+
* The invitation's `admitters` list sits INSIDE the body the group admin
|
|
22
|
+
* signed, so it is authorization: merod answers `403` to an `/admit` from a node
|
|
23
|
+
* that list does not name, whatever else is true of it. The cloud's list is
|
|
24
|
+
* every node currently assigned to the namespace — it cannot know which
|
|
25
|
+
* invitation the caller holds, so it cannot filter by the signed list, and a
|
|
26
|
+
* node assigned *after* the invitation was minted looks perfect in every field
|
|
27
|
+
* the cloud reports and still refuses the claim.
|
|
28
|
+
*
|
|
29
|
+
* Intersecting here is therefore not belt-and-braces: it is the check that
|
|
30
|
+
* turns an unexplainable `403` into "your invitation names none of these nodes,
|
|
31
|
+
* ask for a fresh one". It is never relaxed to make a flow work.
|
|
32
|
+
*
|
|
33
|
+
* # `servable` and `canAdmit` are advisory; `writable` is information
|
|
34
|
+
*
|
|
35
|
+
* `servable` and `canAdmit` are derived from assignment heartbeats, and a lapsed
|
|
36
|
+
* heartbeat is not the same fact as a node that will refuse. Measured against
|
|
37
|
+
* prod: the cloud reported `servable: false, canAdmit: false, fresh: false` for a
|
|
38
|
+
* node whose `/admin-api/health` answered `{"status":"alive"}` and which had just
|
|
39
|
+
* minted the invitation being claimed. Gating on them meant refusing to knock on
|
|
40
|
+
* an open door, so a node with an address is tried and the NODE decides —
|
|
41
|
+
* `admit_join` re-verifies the invitation, the signature and the admitters list,
|
|
42
|
+
* so asking loosens nothing and a refusal names its own cause. `stale` on the
|
|
43
|
+
* result says the attempt rests on advisory data.
|
|
44
|
+
*
|
|
45
|
+
* `writable` — and its per-node form, `canExecute` — is about `CAN_AUTHOR_ON_BEHALF`,
|
|
46
|
+
* which admission does **not** require: relaying a join the joiner signed is not
|
|
47
|
+
* authoring on anyone's behalf. So a non-writable namespace is not a refusal
|
|
48
|
+
* here; it is reported on the result, because it is precisely why the writes
|
|
49
|
+
* would fail *after* connecting, and a caller that cannot tell the two apart
|
|
50
|
+
* sends the user to the wrong place.
|
|
51
|
+
*/
|
|
52
|
+
import { CloudClient } from '../cloud/index.js';
|
|
53
|
+
/**
|
|
54
|
+
* Which step could not be completed. Each one needs a different action from the
|
|
55
|
+
* person, which is why this is an enum and not a message.
|
|
56
|
+
*
|
|
57
|
+
* - `admitters-lookup` — the cloud read itself failed (offline, 5xx, a refused
|
|
58
|
+
* routing proof). Nothing has been learned about the namespace.
|
|
59
|
+
* - `no-nodes` — the read succeeded and the cloud lists nothing, and the
|
|
60
|
+
* invitation carries no http(s) address of an admitter either. The namespace
|
|
61
|
+
* has no fleet assignment, so there is no cloud node to admit through; the
|
|
62
|
+
* invitation's admitters are somebody's self-hosted or desktop node, which the
|
|
63
|
+
* cloud neither knows nor routes to.
|
|
64
|
+
* - `not-invited` — nodes serve the namespace, and the invitation names none of
|
|
65
|
+
* them. Terminal for this invitation: ask for a fresh one.
|
|
66
|
+
* - `invited-unreachable` — a named node exists but cannot take a join now (no
|
|
67
|
+
* fresh heartbeat, or the cloud reports the namespace as not servable). A
|
|
68
|
+
* wait, not a wrong invitation.
|
|
69
|
+
* - `no-relay-url` — a named node can admit but the cloud knows no address for
|
|
70
|
+
* it, so there is nothing to connect through afterwards. Also a wait: it is
|
|
71
|
+
* the shape a node reports before its first heartbeat lands.
|
|
72
|
+
*/
|
|
73
|
+
export type RelayResolutionStep = 'admitters-lookup' | 'no-nodes' | 'not-invited' | 'invited-unreachable' | 'no-relay-url';
|
|
74
|
+
/** A relay that can take this invitation's join, and connect afterwards. */
|
|
75
|
+
export interface ResolvedInvitationRelay {
|
|
76
|
+
readonly ok: true;
|
|
77
|
+
/** The node origin to connect through. */
|
|
78
|
+
readonly relayUrl: string;
|
|
79
|
+
/** Where to POST the signed join — the cloud's own, never rebuilt when given. */
|
|
80
|
+
readonly admitUrl: string;
|
|
81
|
+
/**
|
|
82
|
+
* The admitter's account: as the cloud reported it, or — for a relay the
|
|
83
|
+
* invitation itself named — the one admitter it names, when it names one.
|
|
84
|
+
*/
|
|
85
|
+
readonly admitterAccount: string | null;
|
|
86
|
+
/** The node's peer id as the cloud reported it; `null` for a relay the invitation named. */
|
|
87
|
+
readonly peerId: string | null;
|
|
88
|
+
/**
|
|
89
|
+
* Whether this node can also run delegated writes (`CAN_AUTHOR_ON_BEHALF`).
|
|
90
|
+
*
|
|
91
|
+
* `false` is not a failure of admission — see the module note. It means the
|
|
92
|
+
* connection that follows will read and receive events but have its intents
|
|
93
|
+
* refused, which an admin grants away, so it is worth saying out loud.
|
|
94
|
+
*/
|
|
95
|
+
readonly writable: boolean;
|
|
96
|
+
/** Whether the chosen node's heartbeat for this namespace has lapsed. */
|
|
97
|
+
readonly stale: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Who named this relay: the cloud's routing, or the invitation's own
|
|
100
|
+
* `admitter_addrs` when the cloud routed the namespace to no node.
|
|
101
|
+
*/
|
|
102
|
+
readonly via: 'cloud' | 'invitation';
|
|
103
|
+
}
|
|
104
|
+
export interface RelayResolutionFailure {
|
|
105
|
+
readonly ok: false;
|
|
106
|
+
readonly step: RelayResolutionStep;
|
|
107
|
+
/** What to go and do about it, in one sentence. */
|
|
108
|
+
readonly reason: string;
|
|
109
|
+
}
|
|
110
|
+
export type ResolveRelayResult = ResolvedInvitationRelay | RelayResolutionFailure;
|
|
111
|
+
export interface ResolveRelayFromInvitationInput {
|
|
112
|
+
/** The namespace the invitation admits you to, 64 hex. */
|
|
113
|
+
readonly namespaceId: string;
|
|
114
|
+
/**
|
|
115
|
+
* The invitation's signed `admitters` — `invitation.invitation.admitters`.
|
|
116
|
+
*
|
|
117
|
+
* An EMPTY list is the legacy "admission by broadcast" shape, where any ready
|
|
118
|
+
* peer may admit, and is treated as such. Omitting the field and passing an
|
|
119
|
+
* empty array are therefore the same thing on purpose: there is no spelling
|
|
120
|
+
* here that means "nobody may admit", because an invitation cannot say that.
|
|
121
|
+
*/
|
|
122
|
+
readonly admitters?: readonly string[];
|
|
123
|
+
/**
|
|
124
|
+
* The invitation's `admitter_addrs`: where its admitters are reached. Core
|
|
125
|
+
* emits libp2p multiaddrs here; an account minting through mero-react adds
|
|
126
|
+
* its relay's http(s) origin. Only the http(s) entries are of use to a
|
|
127
|
+
* browser, and only when the cloud routes the namespace to no node — the
|
|
128
|
+
* case of a namespace whose founder never linked the account, which the
|
|
129
|
+
* cloud does not route yet whose own relay admits.
|
|
130
|
+
*/
|
|
131
|
+
readonly admitterAddrs?: readonly string[];
|
|
132
|
+
/** The delegated credential — an `AccountProof<DeviceCert>`, hex. */
|
|
133
|
+
readonly credential: string;
|
|
134
|
+
/** The certified device's ed25519 signing secret, hex. */
|
|
135
|
+
readonly deviceSecret: string;
|
|
136
|
+
/** Point at a cloud other than the hosted manager. For local rigs. */
|
|
137
|
+
readonly cloudBaseUrl?: string;
|
|
138
|
+
/** Injected in tests. Anything with the one method this needs. */
|
|
139
|
+
readonly cloud?: Pick<CloudClient, 'getNamespaceRouting'>;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Normalise a hex account for comparison: lower-cased, `0x` stripped.
|
|
143
|
+
*
|
|
144
|
+
* Both sides are hex for the same 32 bytes, and a spelling mismatch would
|
|
145
|
+
* present as "your invitation does not name this node" — a refusal that looks
|
|
146
|
+
* like policy and is really formatting.
|
|
147
|
+
*/
|
|
148
|
+
export declare function normaliseAccount(account: string | null | undefined): string | null;
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a relay to bootstrap through, from an invitation and nothing else.
|
|
151
|
+
*
|
|
152
|
+
* Reads the cloud's routing for the namespace, intersects it with the
|
|
153
|
+
* invitation's signed `admitters`, and returns the node to use — or which step
|
|
154
|
+
* stopped it and what that means. It performs no admission and mints no keys:
|
|
155
|
+
* the credential passed in is the one the wallet already certified.
|
|
156
|
+
*/
|
|
157
|
+
export declare function resolveRelayFromInvitation(input: ResolveRelayFromInvitationInput): Promise<ResolveRelayResult>;
|