@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
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
/** Borsh `String`: u32 little-endian byte length, then the UTF-8 bytes. */
|
|
13
|
+
function borshString(s) {
|
|
14
|
+
const bytes = new TextEncoder().encode(s);
|
|
15
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
16
|
+
new DataView(out.buffer).setUint32(0, bytes.length, true);
|
|
17
|
+
out.set(bytes, 4);
|
|
18
|
+
return out;
|
|
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 async function applicationIdForBundle(pkg, signerId) {
|
|
25
|
+
const a = borshString(pkg);
|
|
26
|
+
const b = borshString(signerId);
|
|
27
|
+
const tuple = new Uint8Array(a.length + b.length);
|
|
28
|
+
tuple.set(a, 0);
|
|
29
|
+
tuple.set(b, a.length);
|
|
30
|
+
const digest = await crypto.subtle.digest('SHA-256', tuple);
|
|
31
|
+
return Array.from(new Uint8Array(digest), (x) => x.toString(16).padStart(2, '0')).join('');
|
|
32
|
+
}
|
|
33
|
+
/** Whether `a` is a newer version string than `b` (numeric, dot/dash split). */
|
|
34
|
+
function newer(a, b) {
|
|
35
|
+
const [x, y] = [a, b].map((v) => v.split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0));
|
|
36
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
37
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0))
|
|
38
|
+
return (x[i] ?? 0) > (y[i] ?? 0);
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The entry a package resolves to: its newest version that is not yanked. The
|
|
44
|
+
* one rule for "latest", shared by the version lookup and the id derivation so
|
|
45
|
+
* the two cannot disagree about which bundle an account is founding on.
|
|
46
|
+
*/
|
|
47
|
+
export function selectLatestBundle(bundles) {
|
|
48
|
+
return bundles
|
|
49
|
+
.filter((b) => !b.yanked && typeof b.appVersion === 'string' && b.appVersion.length > 0)
|
|
50
|
+
.reduce((best, b) => (!best || newer(b.appVersion, best.appVersion) ? b : best), undefined);
|
|
51
|
+
}
|
|
52
|
+
/** The registry's listing of `pkg`, every version. */
|
|
53
|
+
export async function fetchRegistryBundles(registryUrl, pkg, fetchFn = globalThis.fetch) {
|
|
54
|
+
const url = `${registryUrl.replace(/\/+$/, '')}/api/v2/bundles?package=${encodeURIComponent(pkg)}`;
|
|
55
|
+
const response = await fetchFn(url, { headers: { Accept: 'application/json' } });
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
throw new Error(`the registry has no ${pkg} (HTTP ${response.status})`);
|
|
58
|
+
const body = (await response.json());
|
|
59
|
+
return Array.isArray(body) ? body : [];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The application id an account founds `pkg` on, learned from the registry.
|
|
63
|
+
*
|
|
64
|
+
* Refused when the non-yanked versions of `pkg` name more than one publisher:
|
|
65
|
+
* that is two different applications under one package name (the id binds the
|
|
66
|
+
* signer), and picking either silently would commit the founder — who signs
|
|
67
|
+
* `TargetApplicationSet` naming the id — to an app it did not choose.
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveApplicationIdFromRegistry(registryUrl, pkg, deps = {}) {
|
|
70
|
+
const bundles = await fetchRegistryBundles(registryUrl, pkg, deps.fetch);
|
|
71
|
+
const live = bundles.filter((b) => !b.yanked);
|
|
72
|
+
const signers = [...new Set(live.map((b) => b.signerId).filter((s) => typeof s === 'string' && s.length > 0))];
|
|
73
|
+
if (signers.length > 1) {
|
|
74
|
+
throw new Error(`the registry lists ${pkg} under more than one publisher (${signers.join(', ')}): refusing to guess which application it is`);
|
|
75
|
+
}
|
|
76
|
+
const latest = selectLatestBundle(live);
|
|
77
|
+
if (!latest)
|
|
78
|
+
throw new Error(`the registry lists no version of ${pkg}`);
|
|
79
|
+
const signerId = latest.signerId;
|
|
80
|
+
if (!signerId)
|
|
81
|
+
throw new Error(`the registry names no publisher for ${pkg} ${latest.appVersion}`);
|
|
82
|
+
return { applicationId: await applicationIdForBundle(pkg, signerId), signerId, version: latest.appVersion };
|
|
83
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrapping an account that is a member of nothing, from an invitation.
|
|
3
|
+
*
|
|
4
|
+
* # The one missing step
|
|
5
|
+
*
|
|
6
|
+
* A freshly enrolled account holds a certified device and belongs to no
|
|
7
|
+
* namespace, so `CloudClient.getAccountRelays` answers `[]` — correctly, because
|
|
8
|
+
* nothing serves an account that is a member of nothing. It is still signed in;
|
|
9
|
+
* what it has no way to do is write. An invitation is what changes that, and the
|
|
10
|
+
* chain is:
|
|
11
|
+
*
|
|
12
|
+
* invitation → namespaceId → a node URL
|
|
13
|
+
* → POST the signed join to that node → the account is a member
|
|
14
|
+
* → that same node is the relay, from this moment
|
|
15
|
+
* → a fleet relay writes the recovery envelope asynchronously,
|
|
16
|
+
* and getAccountRelays() agrees from then on
|
|
17
|
+
*
|
|
18
|
+
* The session does not wait for the cloud to catch up. The node that carried the
|
|
19
|
+
* join is known to serve the namespace now; the lookup will agree later.
|
|
20
|
+
*
|
|
21
|
+
* # Where the node URL comes from, in preference order
|
|
22
|
+
*
|
|
23
|
+
* 1. one passed in — an operator's, or one a future invitation carries. Core
|
|
24
|
+
* today emits only libp2p multiaddrs on an invitation (`resolve_admitter_addrs`)
|
|
25
|
+
* because a node cannot know the URL its admin API is proxied on, so there is
|
|
26
|
+
* nothing to read off the invitation yet and this is the seam for when there
|
|
27
|
+
* is;
|
|
28
|
+
* 2. the cloud's `/api/cloud/namespaces/{ns}/admitters`, intersected with the
|
|
29
|
+
* invitation's signed `admitters` — see `resolveRelayFromInvitation`;
|
|
30
|
+
* 3. an http(s) origin in the invitation's `admitter_addrs`, when the cloud
|
|
31
|
+
* routes the namespace to no node: the relay of a founder who never linked
|
|
32
|
+
* the account writes its own origin there when it mints.
|
|
33
|
+
*
|
|
34
|
+
* # Nothing here is minted, weakened or worked around
|
|
35
|
+
*
|
|
36
|
+
* The credential and the device secret come from the enrolment the wallet
|
|
37
|
+
* already performed. The op is signed by that device key, which is what stops an
|
|
38
|
+
* admitter substituting a different member. The admitters intersection is done in
|
|
39
|
+
* `resolveRelayFromInvitation` and is never relaxed.
|
|
40
|
+
*/
|
|
41
|
+
import type { SignedGroupOpenInvitation } from '../admin-api/index.js';
|
|
42
|
+
import type { DelegatedCredential, DelegatedSession } from './session.js';
|
|
43
|
+
import { joinWithNode, type JoinWithNodeStep } from './join-with-node.js';
|
|
44
|
+
import { resolveRelayFromInvitation, type RelayResolutionStep, type ResolvedInvitationRelay } from './relay-from-invitation.js';
|
|
45
|
+
/**
|
|
46
|
+
* Which step failed. One exhaustive list to branch on: the relay-resolution
|
|
47
|
+
* steps, the two `joinWithNode` reports, and the one only a hook can hit.
|
|
48
|
+
*
|
|
49
|
+
* - `no-credential` — this tab holds no enrolled account, so there is nothing to
|
|
50
|
+
* admit. Only {@link useDelegatedBootstrap} can report it;
|
|
51
|
+
* `bootstrapFromInvitation` is handed the credential as an argument.
|
|
52
|
+
*/
|
|
53
|
+
export type BootstrapStep = RelayResolutionStep | JoinWithNodeStep | 'no-credential';
|
|
54
|
+
export interface BootstrapSuccess {
|
|
55
|
+
readonly ok: true;
|
|
56
|
+
/** The session to connect with — hand it straight to `connectWithAccount`. */
|
|
57
|
+
readonly session: DelegatedSession;
|
|
58
|
+
/**
|
|
59
|
+
* How the node was chosen, when the cloud chose it.
|
|
60
|
+
*
|
|
61
|
+
* `null` when the caller named the node itself, because then there was no
|
|
62
|
+
* routing read to report — not because anything was skipped.
|
|
63
|
+
*/
|
|
64
|
+
readonly relay: ResolvedInvitationRelay | null;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the admitter said it published the op.
|
|
67
|
+
*
|
|
68
|
+
* NOT "you are a member": membership lands when peers apply the op, which the
|
|
69
|
+
* admitter neither performs nor waits for. `false` with no error means the node
|
|
70
|
+
* took the call and did not publish — worth surfacing rather than treating as
|
|
71
|
+
* success.
|
|
72
|
+
*/
|
|
73
|
+
readonly published: boolean;
|
|
74
|
+
}
|
|
75
|
+
export interface BootstrapFailure {
|
|
76
|
+
readonly ok: false;
|
|
77
|
+
readonly step: BootstrapStep;
|
|
78
|
+
readonly reason: string;
|
|
79
|
+
/** The HTTP status, when the failing step was an HTTP call. */
|
|
80
|
+
readonly status?: number;
|
|
81
|
+
}
|
|
82
|
+
export type BootstrapResult = BootstrapSuccess | BootstrapFailure;
|
|
83
|
+
export interface BootstrapFromInvitationInput {
|
|
84
|
+
/** The namespace the invitation admits you to, 64 hex. */
|
|
85
|
+
readonly namespaceId: string;
|
|
86
|
+
/** The invitation, exactly as the inviter's node returned it. */
|
|
87
|
+
readonly invitation: SignedGroupOpenInvitation;
|
|
88
|
+
/** The enrolled account, its certificate, and the device secret. */
|
|
89
|
+
readonly credential: DelegatedCredential;
|
|
90
|
+
/**
|
|
91
|
+
* A node to present the join to, skipping the cloud lookup.
|
|
92
|
+
*
|
|
93
|
+
* Use it when something already knows the node — an operator, a local rig, or
|
|
94
|
+
* an invitation that carries a claim URL once core emits one. Without it the
|
|
95
|
+
* cloud's admitters lookup answers, and the invitation's signed list is what
|
|
96
|
+
* narrows the answer.
|
|
97
|
+
*/
|
|
98
|
+
readonly nodeUrl?: string;
|
|
99
|
+
/** Point at a cloud other than the hosted manager. For local rigs. */
|
|
100
|
+
readonly cloudBaseUrl?: string;
|
|
101
|
+
/** Injected in tests. */
|
|
102
|
+
readonly deps?: {
|
|
103
|
+
readonly resolve?: typeof resolveRelayFromInvitation;
|
|
104
|
+
readonly join?: typeof joinWithNode;
|
|
105
|
+
readonly fetch?: typeof fetch;
|
|
106
|
+
readonly nonce?: () => Promise<bigint>;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a node from the invitation, present the signed join to it, and return
|
|
111
|
+
* the session to connect with.
|
|
112
|
+
*
|
|
113
|
+
* Never throws for an expected failure: every one of them is a different thing
|
|
114
|
+
* for the person to do, so they come back as `{ ok: false, step, reason }`. A
|
|
115
|
+
* generic "could not join" is exactly what this shape exists to prevent.
|
|
116
|
+
*/
|
|
117
|
+
export declare function bootstrapFromInvitation(input: BootstrapFromInvitationInput): Promise<BootstrapResult>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrapping an account that is a member of nothing, from an invitation.
|
|
3
|
+
*
|
|
4
|
+
* # The one missing step
|
|
5
|
+
*
|
|
6
|
+
* A freshly enrolled account holds a certified device and belongs to no
|
|
7
|
+
* namespace, so `CloudClient.getAccountRelays` answers `[]` — correctly, because
|
|
8
|
+
* nothing serves an account that is a member of nothing. It is still signed in;
|
|
9
|
+
* what it has no way to do is write. An invitation is what changes that, and the
|
|
10
|
+
* chain is:
|
|
11
|
+
*
|
|
12
|
+
* invitation → namespaceId → a node URL
|
|
13
|
+
* → POST the signed join to that node → the account is a member
|
|
14
|
+
* → that same node is the relay, from this moment
|
|
15
|
+
* → a fleet relay writes the recovery envelope asynchronously,
|
|
16
|
+
* and getAccountRelays() agrees from then on
|
|
17
|
+
*
|
|
18
|
+
* The session does not wait for the cloud to catch up. The node that carried the
|
|
19
|
+
* join is known to serve the namespace now; the lookup will agree later.
|
|
20
|
+
*
|
|
21
|
+
* # Where the node URL comes from, in preference order
|
|
22
|
+
*
|
|
23
|
+
* 1. one passed in — an operator's, or one a future invitation carries. Core
|
|
24
|
+
* today emits only libp2p multiaddrs on an invitation (`resolve_admitter_addrs`)
|
|
25
|
+
* because a node cannot know the URL its admin API is proxied on, so there is
|
|
26
|
+
* nothing to read off the invitation yet and this is the seam for when there
|
|
27
|
+
* is;
|
|
28
|
+
* 2. the cloud's `/api/cloud/namespaces/{ns}/admitters`, intersected with the
|
|
29
|
+
* invitation's signed `admitters` — see `resolveRelayFromInvitation`;
|
|
30
|
+
* 3. an http(s) origin in the invitation's `admitter_addrs`, when the cloud
|
|
31
|
+
* routes the namespace to no node: the relay of a founder who never linked
|
|
32
|
+
* the account writes its own origin there when it mints.
|
|
33
|
+
*
|
|
34
|
+
* # Nothing here is minted, weakened or worked around
|
|
35
|
+
*
|
|
36
|
+
* The credential and the device secret come from the enrolment the wallet
|
|
37
|
+
* already performed. The op is signed by that device key, which is what stops an
|
|
38
|
+
* admitter substituting a different member. The admitters intersection is done in
|
|
39
|
+
* `resolveRelayFromInvitation` and is never relaxed.
|
|
40
|
+
*/
|
|
41
|
+
import { joinWithNode } from './join-with-node.js';
|
|
42
|
+
import { resolveRelayFromInvitation, } from './relay-from-invitation.js';
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a node from the invitation, present the signed join to it, and return
|
|
45
|
+
* the session to connect with.
|
|
46
|
+
*
|
|
47
|
+
* Never throws for an expected failure: every one of them is a different thing
|
|
48
|
+
* for the person to do, so they come back as `{ ok: false, step, reason }`. A
|
|
49
|
+
* generic "could not join" is exactly what this shape exists to prevent.
|
|
50
|
+
*/
|
|
51
|
+
export async function bootstrapFromInvitation(input) {
|
|
52
|
+
const resolve = input.deps?.resolve ?? resolveRelayFromInvitation;
|
|
53
|
+
const join = input.deps?.join ?? joinWithNode;
|
|
54
|
+
let relay = null;
|
|
55
|
+
let nodeUrl = input.nodeUrl;
|
|
56
|
+
let admitUrl;
|
|
57
|
+
if (!nodeUrl) {
|
|
58
|
+
const resolved = await resolve({
|
|
59
|
+
namespaceId: input.namespaceId,
|
|
60
|
+
// The signed list, read off the invitation itself rather than passed
|
|
61
|
+
// separately: two sources for one fact is how an intersection ends up
|
|
62
|
+
// being performed against the wrong list.
|
|
63
|
+
admitters: input.invitation.invitation.admitters,
|
|
64
|
+
admitterAddrs: input.invitation.admitter_addrs,
|
|
65
|
+
credential: input.credential.credential,
|
|
66
|
+
deviceSecret: input.credential.deviceSecret,
|
|
67
|
+
cloudBaseUrl: input.cloudBaseUrl,
|
|
68
|
+
});
|
|
69
|
+
if (resolved.ok === false)
|
|
70
|
+
return resolved;
|
|
71
|
+
relay = resolved;
|
|
72
|
+
nodeUrl = resolved.relayUrl;
|
|
73
|
+
admitUrl = resolved.admitUrl;
|
|
74
|
+
}
|
|
75
|
+
const joined = await join({
|
|
76
|
+
nodeUrl,
|
|
77
|
+
admitUrl,
|
|
78
|
+
namespaceId: input.namespaceId,
|
|
79
|
+
invitation: input.invitation,
|
|
80
|
+
credential: input.credential,
|
|
81
|
+
deps: { fetch: input.deps?.fetch, nonce: input.deps?.nonce },
|
|
82
|
+
});
|
|
83
|
+
if (joined.ok === false)
|
|
84
|
+
return joined;
|
|
85
|
+
return { ok: true, published: joined.published, relay, session: joined.session };
|
|
86
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type GovernanceOp } from '../warrant/index.js';
|
|
2
|
+
import { type DelegatedSession } from './session.js';
|
|
3
|
+
/**
|
|
4
|
+
* What an account asks for when it creates a context: the fields of a node's
|
|
5
|
+
* `CreateContextRequest` that have a place in the creation warrant
|
|
6
|
+
* (mero-js `CreationWarrantInput`), plus which namespace to find the relay by.
|
|
7
|
+
*
|
|
8
|
+
* Intentionally NOT here, because the warrant has no field for it:
|
|
9
|
+
* `identitySecret` — a node's key for its new member identity; the account IS
|
|
10
|
+
* the member identity here (`init` runs as the author's account), so there is
|
|
11
|
+
* nothing to derive one from.
|
|
12
|
+
*/
|
|
13
|
+
export interface CreateDelegatedContextRequest {
|
|
14
|
+
readonly namespaceId: string;
|
|
15
|
+
/** The group to create it in; the namespace itself when omitted. */
|
|
16
|
+
readonly groupId?: string;
|
|
17
|
+
readonly applicationId: string;
|
|
18
|
+
/**
|
|
19
|
+
* Which service of a multi-service bundle the context is for; absent for
|
|
20
|
+
* the bundle's default service. Signed into the warrant: the relay runs THAT
|
|
21
|
+
* service's `init`, so a bundle like mero-docs (`registry`, `docs`) creates
|
|
22
|
+
* the right contract.
|
|
23
|
+
*/
|
|
24
|
+
readonly serviceName?: string;
|
|
25
|
+
/** The seed the context id is derived from, hex (32 bytes); random when absent. */
|
|
26
|
+
readonly contextSeed?: string;
|
|
27
|
+
readonly initializationParams?: number[];
|
|
28
|
+
readonly name?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function createDelegatedContext(s: DelegatedSession, req: CreateDelegatedContextRequest, deps?: {
|
|
31
|
+
fetch?: typeof fetch;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
contextId: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** Publish one delegable governance op through the namespace's relay. */
|
|
36
|
+
export declare function delegatedGovernance(s: DelegatedSession, req: {
|
|
37
|
+
namespaceId: string;
|
|
38
|
+
group: string;
|
|
39
|
+
op: GovernanceOp;
|
|
40
|
+
}, deps?: {
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
groupId: string;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* A context only the author and `members` are in (a DM, a small room): a
|
|
47
|
+
* restricted subgroup of the namespace (a root op, posted to the namespace),
|
|
48
|
+
* each member added to it directly — they are in the namespace already, so
|
|
49
|
+
* nobody is invited — and the context created inside it. The relay that
|
|
50
|
+
* creates the subgroup is seated in it with CAN_AUTHOR_ON_BEHALF by core.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createDelegatedPrivateContext(s: DelegatedSession, req: CreateDelegatedContextRequest & {
|
|
53
|
+
readonly members: readonly string[];
|
|
54
|
+
}, deps?: {
|
|
55
|
+
fetch?: typeof fetch;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
contextId: string;
|
|
58
|
+
groupId: string;
|
|
59
|
+
}>;
|
|
60
|
+
/**
|
|
61
|
+
* Found a namespace as the account, through a relay it already uses. The
|
|
62
|
+
* account becomes founder, owner and admin; the relay is seated in it (and a
|
|
63
|
+
* TEE relay admits itself as its first TEE), so contexts can be created in it
|
|
64
|
+
* through the same relay straight away.
|
|
65
|
+
*
|
|
66
|
+
* The relay's executor account cannot be asked about a namespace that does
|
|
67
|
+
* not exist yet, so it is the session's `executorAccount` when the app named
|
|
68
|
+
* one, else learned from a namespace the account already has on that relay. A
|
|
69
|
+
* brand-new account with neither is told how to get one.
|
|
70
|
+
*/
|
|
71
|
+
export declare function foundDelegatedNamespace(s: DelegatedSession, req?: {
|
|
72
|
+
readonly defaultCapabilities?: number;
|
|
73
|
+
/** The application the namespace runs; without one no context can be created in it. */
|
|
74
|
+
readonly application?: {
|
|
75
|
+
applicationId: string;
|
|
76
|
+
package: string;
|
|
77
|
+
version: string;
|
|
78
|
+
};
|
|
79
|
+
}, deps?: {
|
|
80
|
+
fetch?: typeof fetch;
|
|
81
|
+
/** The cloud to enable HA on; mero-js's default when unset. */
|
|
82
|
+
cloudBaseUrl?: string;
|
|
83
|
+
}): Promise<FoundedDelegatedNamespace>;
|
|
84
|
+
/** What {@link foundDelegatedNamespace} returns. */
|
|
85
|
+
export interface FoundedDelegatedNamespace {
|
|
86
|
+
namespaceId: string;
|
|
87
|
+
teeEnabled: boolean;
|
|
88
|
+
/** Whether the cloud agreed to host the namespace (HA) right after founding. */
|
|
89
|
+
haEnabled: boolean;
|
|
90
|
+
/** Why `haEnabled` is `false`, in words a person can act on. */
|
|
91
|
+
haError?: string;
|
|
92
|
+
}
|
|
93
|
+
/** What an app can tell a person whose account the cloud cannot place. */
|
|
94
|
+
export declare const HA_ACCOUNT_NOT_LINKED_MESSAGE = "link this account to your cloud user in the wallet so invitees can find this namespace";
|
|
95
|
+
/**
|
|
96
|
+
* What to tell a person for each refusal the cloud names, keyed by its code.
|
|
97
|
+
* Every one leaves the namespace founded; only hosting was refused.
|
|
98
|
+
*/
|
|
99
|
+
export declare const HA_REFUSAL_MESSAGES: Record<string, string>;
|
|
100
|
+
/**
|
|
101
|
+
* The latest version of `pkg` the registry publishes. Used when the app names
|
|
102
|
+
* its package but not a version. Same selection as the id derivation
|
|
103
|
+
* ({@link selectLatestBundle}): the newest version that is not yanked.
|
|
104
|
+
*/
|
|
105
|
+
export declare function latestPublishedVersion(registryUrl: string, pkg: string, fetchFn?: typeof fetch): Promise<string>;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An account's governance through the relay that serves its namespace: create
|
|
3
|
+
* a context, and the group ops a private context (a DM) needs.
|
|
4
|
+
*
|
|
5
|
+
* A thin layer over mero-js's `RelayClient.createContext` / `govern` (core's
|
|
6
|
+
* `/admin-api/groups/{group}/context-intents` and `.../governance-intents`),
|
|
7
|
+
* adding what only this package knows: which relay serves the namespace (the
|
|
8
|
+
* relay map), and the nonce bookkeeping across page loads.
|
|
9
|
+
*
|
|
10
|
+
* Nodes check the AUTHOR's rights (CAN_CREATE_CONTEXT, CAN_CREATE_SUBGROUP,
|
|
11
|
+
* MANAGE_MEMBERS…), never the relay's; the relay only needs standing to act.
|
|
12
|
+
*/
|
|
13
|
+
import { AccountHaRefusedError, AccountNotLinkedError, CloudClient, } from '../cloud/index.js';
|
|
14
|
+
import { memberAddedOp, subgroupCreation, } from '../warrant/index.js';
|
|
15
|
+
import { RelayClient, } from '../relay/index.js';
|
|
16
|
+
import { knownRelays, markContextNonceSpent, readRelayMap, rememberRelay } from './session.js';
|
|
17
|
+
import { fetchRegistryBundles, selectLatestBundle } from './application-id.js';
|
|
18
|
+
/**
|
|
19
|
+
* A governance warrant's nonce, for one (relay, group): spent in a sliding
|
|
20
|
+
* per-(group, device) window 64 wide, so it must never go backwards. A stored
|
|
21
|
+
* counter, floored at the clock so a cleared storage resumes above anything
|
|
22
|
+
* spent before rather than replaying it.
|
|
23
|
+
*/
|
|
24
|
+
function governanceNonce(relay, group) {
|
|
25
|
+
return { next: async () => nextGovernanceNonce(relay, group) };
|
|
26
|
+
}
|
|
27
|
+
function nextGovernanceNonce(relay, group) {
|
|
28
|
+
const key = `calimero.governance-nonce.${relay}.${group}`;
|
|
29
|
+
let last = 0n;
|
|
30
|
+
try {
|
|
31
|
+
last = BigInt(localStorage.getItem(key) ?? '0');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* no storage: the clock floor alone */
|
|
35
|
+
}
|
|
36
|
+
const next = [last + 1n, BigInt(Date.now())].reduce((a, b) => (a > b ? a : b));
|
|
37
|
+
try {
|
|
38
|
+
localStorage.setItem(key, String(next));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* unpersisted */
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
function relayFor(s, namespaceId) {
|
|
46
|
+
// The relay that serves this namespace: learned when the account joined it.
|
|
47
|
+
const url = readRelayMap(s.account).namespaces[namespaceId] ?? s.relayUrl;
|
|
48
|
+
if (!url)
|
|
49
|
+
throw new Error('no relay is known for this namespace, so there is nowhere to send this');
|
|
50
|
+
return url.replace(/\/+$/, '');
|
|
51
|
+
}
|
|
52
|
+
/** The `init` arguments as the JSON the route takes, from the bytes a caller may hold. */
|
|
53
|
+
function initArgsOf(params) {
|
|
54
|
+
if (!params || params.length === 0)
|
|
55
|
+
return {};
|
|
56
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(params)));
|
|
57
|
+
}
|
|
58
|
+
/** A relay client for these calls: the author's credential, whatever nonces the call needs. */
|
|
59
|
+
function client(s, relayUrl, nonces, fetch) {
|
|
60
|
+
return new RelayClient({
|
|
61
|
+
relayUrl,
|
|
62
|
+
authorAccount: s.account,
|
|
63
|
+
authorProof: s.credential,
|
|
64
|
+
deviceSecret: s.deviceSecret,
|
|
65
|
+
nonces,
|
|
66
|
+
fetch,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function createDelegatedContext(s, req, deps = {}) {
|
|
70
|
+
const relay = relayFor(s, req.namespaceId);
|
|
71
|
+
// The creation warrant's nonce is spent in the NEW context's per-device
|
|
72
|
+
// window — the one its writes draw from, empty until now — so the first
|
|
73
|
+
// number is free, and the context's writes then start above it.
|
|
74
|
+
const creationNonce = 0n;
|
|
75
|
+
const created = await client(s, relay, { next: async () => creationNonce }, deps.fetch).createContext({
|
|
76
|
+
groupId: req.groupId ?? req.namespaceId,
|
|
77
|
+
applicationId: req.applicationId,
|
|
78
|
+
serviceName: req.serviceName,
|
|
79
|
+
seed: req.contextSeed,
|
|
80
|
+
initArgs: initArgsOf(req.initializationParams),
|
|
81
|
+
name: req.name,
|
|
82
|
+
});
|
|
83
|
+
markContextNonceSpent(relay, created.contextId, creationNonce);
|
|
84
|
+
rememberRelay(s.account, relay, { namespaceId: req.namespaceId, contextId: created.contextId });
|
|
85
|
+
return { contextId: created.contextId };
|
|
86
|
+
}
|
|
87
|
+
/** Publish one delegable governance op through the namespace's relay. */
|
|
88
|
+
export async function delegatedGovernance(s, req, deps = {}) {
|
|
89
|
+
const relay = relayFor(s, req.namespaceId);
|
|
90
|
+
return client(s, relay, governanceNonce(relay, req.group), deps.fetch).govern({ groupId: req.group, op: req.op });
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A context only the author and `members` are in (a DM, a small room): a
|
|
94
|
+
* restricted subgroup of the namespace (a root op, posted to the namespace),
|
|
95
|
+
* each member added to it directly — they are in the namespace already, so
|
|
96
|
+
* nobody is invited — and the context created inside it. The relay that
|
|
97
|
+
* creates the subgroup is seated in it with CAN_AUTHOR_ON_BEHALF by core.
|
|
98
|
+
*/
|
|
99
|
+
export async function createDelegatedPrivateContext(s, req, deps = {}) {
|
|
100
|
+
const { groupId } = await delegatedGovernance(s, {
|
|
101
|
+
namespaceId: req.namespaceId,
|
|
102
|
+
group: req.namespaceId,
|
|
103
|
+
// The id is derived from a fresh salt (core#4244); a node refuses any other.
|
|
104
|
+
op: (await subgroupCreation({ parentId: req.namespaceId, restricted: true, admin: s.account })).op,
|
|
105
|
+
}, deps);
|
|
106
|
+
for (const member of req.members) {
|
|
107
|
+
await delegatedGovernance(s, { namespaceId: req.namespaceId, group: groupId, op: memberAddedOp(member, 'Member') }, deps);
|
|
108
|
+
}
|
|
109
|
+
const { contextId } = await createDelegatedContext(s, { ...req, groupId }, deps);
|
|
110
|
+
return { contextId, groupId };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Found a namespace as the account, through a relay it already uses. The
|
|
114
|
+
* account becomes founder, owner and admin; the relay is seated in it (and a
|
|
115
|
+
* TEE relay admits itself as its first TEE), so contexts can be created in it
|
|
116
|
+
* through the same relay straight away.
|
|
117
|
+
*
|
|
118
|
+
* The relay's executor account cannot be asked about a namespace that does
|
|
119
|
+
* not exist yet, so it is the session's `executorAccount` when the app named
|
|
120
|
+
* one, else learned from a namespace the account already has on that relay. A
|
|
121
|
+
* brand-new account with neither is told how to get one.
|
|
122
|
+
*/
|
|
123
|
+
export async function foundDelegatedNamespace(s, req = {}, deps = {}) {
|
|
124
|
+
const relays = knownRelays(s).map((u) => u.replace(/\/+$/, ''));
|
|
125
|
+
const relay = relays[0];
|
|
126
|
+
if (!relay)
|
|
127
|
+
throw new Error('no relay is known for this account, so there is nowhere to found a namespace');
|
|
128
|
+
const known = Object.entries(readRelayMap(s.account).namespaces).find(([, url]) => url.replace(/\/+$/, '') === relay)?.[0];
|
|
129
|
+
// The session's relay may come with its account (the cloud names it), which
|
|
130
|
+
// is all founding needs; otherwise it is learned from a namespace on it.
|
|
131
|
+
const named = s.executorAccount && s.relayUrl && s.relayUrl.replace(/\/+$/, '') === relay ? s.executorAccount : null;
|
|
132
|
+
if (!named && !known) {
|
|
133
|
+
throw new Error("the executor account of this relay is not known: join a namespace on it first, or connect with the relay's executor account (the cloud shows it beside the relay)");
|
|
134
|
+
}
|
|
135
|
+
const executorAccount = named ?? (await client(s, relay, governanceNonce(relay, known), deps.fetch).describeGovernance(known)).executorAccount;
|
|
136
|
+
// The founding warrant and, with a default mask, a second one are both spent
|
|
137
|
+
// in the NEW namespace's window, which is empty: any rising pair will do.
|
|
138
|
+
let next = BigInt(Date.now());
|
|
139
|
+
const founded = await client(s, relay, { next: async () => next++ }, deps.fetch).foundNamespace({
|
|
140
|
+
executorAccount,
|
|
141
|
+
...(req.defaultCapabilities !== undefined ? { defaultCapabilities: req.defaultCapabilities } : {}),
|
|
142
|
+
...(req.application ? { application: req.application } : {}),
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
localStorage.setItem(`calimero.governance-nonce.${relay}.${founded.namespaceId}`, String(next));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* unpersisted: the clock floor still keeps later warrants above these */
|
|
149
|
+
}
|
|
150
|
+
rememberRelay(s.account, relay, { namespaceId: founded.namespaceId });
|
|
151
|
+
if (req.application && founded.applicationSet !== true) {
|
|
152
|
+
// Founded, but no context can be created in it until it has its
|
|
153
|
+
// application; the same choice can be retried (it is still the first).
|
|
154
|
+
throw new Error(`founded ${founded.namespaceId} but could not give it its application: ${founded.applicationError ?? 'unknown reason'}`);
|
|
155
|
+
}
|
|
156
|
+
// HA's fleet node is admitted by the founding relay, which can vouch for it
|
|
157
|
+
// only once it attested itself as the namespace's first TEE (that also sets
|
|
158
|
+
// the admission policy). Without that no fleet node is ever admitted, and the
|
|
159
|
+
// request would hold the account's one pending slot in the cloud for good.
|
|
160
|
+
const ha = founded.teeEnabled
|
|
161
|
+
? await enableHaBestEffort(s, founded.namespaceId, founded.salt, relay, deps)
|
|
162
|
+
: {
|
|
163
|
+
haEnabled: false,
|
|
164
|
+
haError: `the relay did not attest the founding, so no fleet node could be admitted for HA${founded.teeError ? `: ${founded.teeError}` : ''}`,
|
|
165
|
+
};
|
|
166
|
+
return { namespaceId: founded.namespaceId, teeEnabled: founded.teeEnabled, ...ha };
|
|
167
|
+
}
|
|
168
|
+
/** What an app can tell a person whose account the cloud cannot place. */
|
|
169
|
+
export const HA_ACCOUNT_NOT_LINKED_MESSAGE = 'link this account to your cloud user in the wallet so invitees can find this namespace';
|
|
170
|
+
/**
|
|
171
|
+
* What to tell a person for each refusal the cloud names, keyed by its code.
|
|
172
|
+
* Every one leaves the namespace founded; only hosting was refused.
|
|
173
|
+
*/
|
|
174
|
+
export const HA_REFUSAL_MESSAGES = {
|
|
175
|
+
account_not_linked: HA_ACCOUNT_NOT_LINKED_MESSAGE,
|
|
176
|
+
account_linked_to_several_users: 'this account is linked to more than one cloud user, so the cloud cannot tell whose plan hosts the namespace: unlink it from all but one in the wallet',
|
|
177
|
+
ha_request_pending: "another namespace of this account is still waiting to be hosted; the cloud hosts one new namespace at a time without a cloud sign-in. Sign in to the cloud and turn off hosting for the waiting namespace, or wait until it is hosted",
|
|
178
|
+
unknown_relay: 'the cloud does not run the relay this namespace was founded on, so it cannot host it',
|
|
179
|
+
relay_not_dialable: 'the relay this namespace was founded on has not reported its address to the cloud yet; try enabling hosting again shortly',
|
|
180
|
+
};
|
|
181
|
+
/** The namespace the cloud says is blocking, when it names one. */
|
|
182
|
+
function blockingNamespace(e) {
|
|
183
|
+
try {
|
|
184
|
+
const body = JSON.parse(e.bodyText ?? '');
|
|
185
|
+
const id = body?.detail?.namespace_id ?? body?.namespace_id;
|
|
186
|
+
return typeof id === 'string' && /^[0-9a-f]{64}$/i.test(id) ? id.toLowerCase() : null;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Ask the cloud to host a namespace this account just founded, with no cloud
|
|
194
|
+
* session: the account proves it is the founder (its id and the salt reproduce
|
|
195
|
+
* the namespace id) and the cloud bills the user it is linked to.
|
|
196
|
+
*
|
|
197
|
+
* Best-effort: the namespace exists whatever the cloud answers, so a refusal is
|
|
198
|
+
* reported, never thrown. Without HA nothing in the cloud knows the namespace,
|
|
199
|
+
* so an invitee cannot find its relay; hence the not-linked case gets a
|
|
200
|
+
* sentence that says what to do.
|
|
201
|
+
*/
|
|
202
|
+
async function enableHaBestEffort(s, namespaceId, salt, relayUrl, deps) {
|
|
203
|
+
try {
|
|
204
|
+
await new CloudClient({ cloudBaseUrl: deps.cloudBaseUrl, fetch: deps.fetch }).enableHaAsAccount({
|
|
205
|
+
namespaceId,
|
|
206
|
+
salt,
|
|
207
|
+
accountId: s.account,
|
|
208
|
+
credential: s.credential,
|
|
209
|
+
deviceSecret: s.deviceSecret,
|
|
210
|
+
// The relay that founded it: the cloud hands it to the fleet node as its
|
|
211
|
+
// admitter, so the node does not have to find it by discovery.
|
|
212
|
+
relayUrl,
|
|
213
|
+
});
|
|
214
|
+
return { haEnabled: true };
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
if (e instanceof AccountNotLinkedError)
|
|
218
|
+
return { haEnabled: false, haError: HA_ACCOUNT_NOT_LINKED_MESSAGE };
|
|
219
|
+
if (e instanceof AccountHaRefusedError && HA_REFUSAL_MESSAGES[e.code]) {
|
|
220
|
+
const blocking = e.code === 'ha_request_pending' ? blockingNamespace(e) : null;
|
|
221
|
+
const message = HA_REFUSAL_MESSAGES[e.code];
|
|
222
|
+
return { haEnabled: false, haError: blocking ? `${message} (waiting: ${blocking})` : message };
|
|
223
|
+
}
|
|
224
|
+
return { haEnabled: false, haError: e instanceof Error ? e.message : String(e) };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The latest version of `pkg` the registry publishes. Used when the app names
|
|
229
|
+
* its package but not a version. Same selection as the id derivation
|
|
230
|
+
* ({@link selectLatestBundle}): the newest version that is not yanked.
|
|
231
|
+
*/
|
|
232
|
+
export async function latestPublishedVersion(registryUrl, pkg, fetchFn = globalThis.fetch) {
|
|
233
|
+
const latest = selectLatestBundle(await fetchRegistryBundles(registryUrl, pkg, fetchFn));
|
|
234
|
+
if (!latest)
|
|
235
|
+
throw new Error(`the registry lists no version of ${pkg}`);
|
|
236
|
+
return latest.appVersion;
|
|
237
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An account's group management through a relay: one delegated governance op
|
|
3
|
+
* per call (`RelayClient.govern`), posted to the relay that serves the group's
|
|
4
|
+
* namespace.
|
|
5
|
+
*
|
|
6
|
+
* A group op goes to the group it acts on; a root op (create, delete, move a
|
|
7
|
+
* subgroup; join an open one) goes to the NAMESPACE. The namespace of a
|
|
8
|
+
* subgroup is remembered here per account: when this account created it, and
|
|
9
|
+
* when the account admin asked the relay for it (`getGroupInfo`).
|
|
10
|
+
*/
|
|
11
|
+
import { type GovernanceOp } from '../warrant/index.js';
|
|
12
|
+
import { type DelegatedSession } from './session.js';
|
|
13
|
+
/** Record that `groupId` is a subgroup of `namespaceId`, for this account. */
|
|
14
|
+
export declare function rememberGroupNamespace(account: string, groupId: string, namespaceId: string): void;
|
|
15
|
+
/** The namespace a group belongs to: itself if it is a namespace this account is in, else what was remembered. */
|
|
16
|
+
export declare function namespaceOfGroup(s: DelegatedSession, groupId: string): string | undefined;
|
|
17
|
+
/** A group op (member, role, capabilities, metadata, visibility, detach) on `groupId`. */
|
|
18
|
+
export declare function governGroup(s: DelegatedSession, groupId: string, op: GovernanceOp): Promise<{
|
|
19
|
+
groupId: string;
|
|
20
|
+
}>;
|
|
21
|
+
/** A root op, posted to the namespace `groupId` belongs to. */
|
|
22
|
+
export declare function governRoot(s: DelegatedSession, groupId: string, op: GovernanceOp): Promise<{
|
|
23
|
+
groupId: string;
|
|
24
|
+
}>;
|