Skip to content

Organizations

Every change to who holds access (members, team members, capabilities and invites) is approved with the caller’s passkey: the node answers 412 CONFIRM_REQUIRED and the client signs the challenge with passkey.

const org = await client.createOrg('Acme');
await client.getOrg(org); // with the teams you reach or manage
await client.setMember(org, bobId, 'member', passkey); // guest | member | admin | owner
await client.removeMember(org, bobId, passkey); // your own account id to leave
await client.setOrgMetadata(org, { logo: '…' });
await client.setMemberMetadata(org, bobId, { title: 'engineer' }); // null clears it
await client.deleteOrg(org, passkey); // the owner's passkey; takes its teams, invites and apps

Ownership moves with setMember(org, account, 'owner', passkey).

An owner who loses every passkey gets the organization back with its recovery seed. The seed never leaves the client: the node stores only its public key and checks a signature the seed makes for the recovering account.

import { newRecoverySeed, parseRecoverySeed, recoveryPublicKey, recoverySeedText } from '@calimero-network/mero-js';
const seed = newRecoverySeed();
showToOwner(recoverySeedText(seed)); // 64 hex digits; keep it offline
const org = await client.createOrg('Acme', { recoveryKey: await recoveryPublicKey(seed) });
// later, or for an organization created without one (the owner, with a passkey):
await client.setRecoveryKey(org, await recoveryPublicKey(newRecoverySeed()), passkey);
// Every passkey lost: sign up again with a new passkey, then
const { readyAt } = await fresh.startRecovery(org, parseRecoverySeed(written));
// 72 hours later (the owner may cancelRecovery meanwhile; every member sees getOrg(org).recovery.pending)
await fresh.finishRecovery(org, newPasskey); // this account owns it; the old owner leaves it

Rotating the key (setRecoveryKey) cancels a recovery under way, and the old seed is refused from then on.

const token = await client.invite(org, 'member', passkey, [teamId], { ttlSecs: 3600, uses: 5 });
// the invitee, signed in (no approval needed):
await invitee.redeemInvite(token);

Someone without an account yet signs up with the invite and joins the organization in the same step. On a cluster whose sign-up policy is invite this is the only way in; read the policy first, without a session, to say so before any passkey prompt:

const { policy, inviteRequired } = await client.signUpPolicy(); // GET /sign-up
await client.signUp(passkey, 'bob', { invite: token });

An unknown, expired or used invite, or a sign-up the policy does not take, is SignUpRefused, with reason (invite-only, closed, unknown-invite, expired-invite, org-gone), raised before the browser makes a passkey.

const team = await client.createTeam(org, 'eng', 'invite'); // or 'everyone'; optional parent
await client.setTeamMember(org, team, bobId, passkey, { role: 'readOnly' }); // lead | member | readOnly
await client.updateTeam(org, team, { name: 'platform', parent: null, metadata: { colour: 'blue' } });
await client.removeTeamMember(org, team, bobId, passkey);
await client.deleteTeam(org, team); // and the teams below it, and their apps

An app created with access: { kind: 'team', team } is reached by the team’s members; a read-only member’s calls that would write are refused.

Below admin, what a member may do is their role plus capability bits:

import { ORG_CAPABILITIES, orgCapabilities } from '@calimero-network/mero-js';
await client.setCapabilities(org, orgCapabilities('CREATE_APP', 'INVITE_MEMBERS'), passkey, { account: bobId });
await client.setCapabilities(org, ORG_CAPABILITIES.CREATE_APP, passkey, { account: bobId, team }); // on one team
await client.setCapabilities(org, ORG_CAPABILITIES.JOIN_OPEN_TEAMS, passkey); // the organization's default
Bit Value Lets a member
CREATE_APP 1 create apps (on its team, or org-wide)
INVITE_MEMBERS 2 mint invites
JOIN_OPEN_TEAMS 4 be in open teams (the default)
MANAGE_MEMBERS 8 add and remove a team’s members
MANAGE_APPLICATION 16 upgrade the org’s apps, start upgrade runs, approve outbound hosts
CREATE_TEAM 32 create a top-level team
DELETE_TEAM 64 delete a team
MANAGE_VISIBILITY 128 change who may join a team (open or invite-only)
MANAGE_METADATA 256 set the organization’s metadata

Upgrade every app of the organization that runs a package, with one passkey approval for the whole run, then follow it:

let run = await client.upgradeOrg(org, { package: 'com.example.kv', version: '1.1.0', team, cascade: true }, passkey);
while (!run.finished) {
await new Promise((r) => setTimeout(r, 1000));
run = await client.upgradeRun(org, run.runId);
}
console.log(run.done, run.failed); // failed: app id -> why
if (Object.keys(run.failed).length) await client.retryUpgradeRun(org, run.runId, passkey); // a new run

The org’s owner, or a member holding MANAGE_APPLICATION org-wide, starts one.

Where the cluster leaves these decisions to the owner side (the default), they are made with a passkey and logged as consent. These calls belong to a page on the cluster’s own origin (a console); a session opened on an app’s frontend is refused them, and an app sends its user to the consent page instead.

Releases. Nodes on a mero-tee release get an organization’s data only once it is approved for that organization (the release the org began on is approved at creation). A member holding APPROVE_RELEASES approves (the owner and admins hold it; getOrg(org).releaseApprovers lists who does). An app’s releases are its publisher’s to approve: the organization its package’s signing key is bound to, for every instance of the key’s packages; for an unbound package, the app’s own organization or account. The operator can give a release a deadline (releaseRules() lists them): past it, a scope that has not approved it (or newer) creates no apps, installs nothing and upgrades nothing (ReleaseDeadlineError, getOrg(org).newWorkStopped), while its running apps keep working:

await client.approveRelease(org, '2.3.120', passkey);
await client.approveRelease(null, '2.3.120', passkey); // the account's own apps
await client.approveRelease(org, '2.3.110', passkey, false); // withdraw

Outbound HTTPS. An app may call out only to URL prefixes approved for its package’s publisher:

await client.approveOutbound(org, 'com.example.kv', passkey); // every prefix its latest release declares
await client.approveOutbound(org, 'com.example.kv', passkey, { prefixes: ['https://api.example/'], replace: true });
await client.approveOutbound(null, 'com.example.kv', passkey); // the account's own apps

Publishers. An organization becomes the publisher of a signing key’s packages only with the key’s own signature:

import { publisherBindingBytes } from '@calimero-network/mero-js';
const { cluster, generation } = await client.publisherBinding(signerId);
const signature = sign(publisherBindingBytes(cluster, org, signerId, generation)); // the key's Ed25519, base64url
await client.bindPublisher(org, { signerId, publicKey, signature }, passkey); // a member holding APPROVE_RELEASES

A package whose manifest declares upgrades: "publisher" is then upgraded by the publisher’s MANAGE_APPLICATION holders, every instance at once:

const run = await client.upgradePublished(org, 'com.example.kv', { version: '1.2.0' }, passkey);

Without upgrades (or with "owner"), each instance’s owner upgrades it. A package may move from publisher to owner in a later release, never back.

Reading what waits. appsReport(org) (any member) answers the approved releases, the releases the cluster’s nodes run and whether each is approved, and per app its release, the newer ones it may move to and its last upgrade; pendingApprovals(org) sums it up.