# Overview
What HALO is and how its pages are organized.
HALO is identity management for local-first apps. It handles a user's identity, the devices and credentials that act for it, and who can join a space.
An identity in HALO is a key pair. The client generates it on the device, and no server issues it or holds the private key. The identity signs the user's changes, and peers check those signatures instead of asking a server who the user is.
HALO is built on [Keyhive](https://github.com/inkandswitch/keyhive), a capability-based access control and group encryption system for local-first data. Keyhive records who may do what as signed delegations that every peer can check, and it keeps the keys that encrypt a space for its current members.
## The `@dxos/halo` package
The package has three namespaces:
- **Identity** creates, reads and recovers the user's identity, lists its devices, and invites a new device.
- **Invitation** drives one invitation once it exists: its code, its events, and the step where the guest enters the authorization code.
- **Space** creates spaces, invites people to them, and manages each member's role.
## Ways to establish an identity on a device
1. Create a new identity from the app.
2. Accept a device invitation from a device that already has the identity.
3. Recover the identity with a recovery code, sometimes called a paper key.
4. Recover the identity with a [passkey](https://www.passkeys.io/).
## How these pages relate to ECHO
ECHO stores and replicates the data in a space; HALO decides who may join it. The [ECHO learning path](/docs/echo/learn/the-echo-object/)
creates an identity in one line so it can open a space, and its [Sharing a space](/docs/echo/learn/sharing/) page invites a
second person.
The HALO learning path builds an app of its own, a [contact book](/docs/halo/learn/the-halo-identity/): a name, a few named
spaces, the people in them, a second device and a recovery code.
---
# Why HALO?
Why identity is a key pair your device makes, not an account a server issues.
In most apps a user exists because a server says so: it holds the account, checks the password and can take the account
away. A local-first app cannot depend on that server, because its data has to work offline and between peers. HALO makes
the user a key pair the device generates, and lets peers check each other's keys directly.
## The pattern
Create an identity, create a space, invite someone. No step talks to a sign-up server.
```ts
import * as Effect from 'effect/Effect';
import { Identity, Invitation, Space } from '@dxos/halo';
const start = Effect.gen(function* () {
// a key pair, made on this device
const me = yield* Identity.create({ displayName: 'Ada' });
const team = yield* Space.create({ name: 'Team' });
const flow = yield* Space.share(team.id, { authMethod: 'shared-secret' });
// send the code; no sign-up server involved
return { me, invite: yield* Invitation.code(flow) };
});
```
## An account against a key pair
| | Account on a server | HALO identity |
| ------------------------------- | ---------------------------------------------------- | ------------------------------------------------------ |
| Who creates it | The server, after sign-up | The device, with `Identity.create` |
| What proves who you are | A password or an OAuth token, checked by the server | A signature from your device key, checked by any peer |
| Who decides access to data | The server, on every request | The space's members, through signed membership |
| Works offline | No | Yes |
| Can be taken away by the provider | Yes | No; only your own devices hold the keys |
| Lost password | Reset by email | Recover with a recovery credential set up in advance |
## Practical problems
### One identity across devices
A user expects their laptop and phone to be the same person. HALO admits each new device with a device invitation from one
that already has the identity, so every device has its own key and the identity vouches for all of them. A lost phone
takes only its own key with it. [Add a device](/docs/halo/learn/add-device/) walks through it.
### Access that peers check without a server
When two peers sync a space, each one decides whether to accept the other's changes. Membership is a
[Keyhive](https://github.com/inkandswitch/keyhive) graph of signed delegations that every peer holds, so the check needs
no server and still works when the peers meet on a local network.
[Spaces and membership](/docs/halo/concepts/spaces-membership/) explains the graph.
### Recovery without a password reset
There is no server that can reset a key pair. Instead the user sets up a recovery credential, such as a recovery code or a
passkey, while they still have a device, and uses it to admit a new device later.
[Recover an identity](/docs/halo/learn/recover-identity/) shows how.
## What HALO does not do
HALO is the identity of a user inside DXOS apps and spaces. It is not a login for third-party websites, and it does not
replace OAuth for signing in to services outside DXOS.
Start with [The HALO identity](/docs/halo/learn/the-halo-identity/).
---
# The HALO identity
What an identity is, and the app the learning path builds, running for three people.
The learning path builds one app: a contact book. You give yourself a name, keep a few named spaces, and invite people
into them. Your contacts are the people you share a space with. This page explains what a HALO identity is, and shows the
contact book finished.
## What an identity is
Everything the contact book shows comes from one identity per person. An identity has four parts:
- **A DID.** Peers know the identity by this one name, on every device and in every space.
- **A profile.** Its display name, which every space the identity is in shows.
- **Members.** An identity is a Keyhive group, and its members are the keys allowed to act for it. Each device holds its
own key, so adding a device adds a member and removing one revokes it. The key behind a recovery code is a member too.
- **Spaces.** A space is a group as well, and the identity is one of its members, with an access level. The identity's
devices act for it in every space it belongs to.
No server issues or holds an identity. Each change to its members is a signed delegation or revocation, and every peer
checks it. [Identities and devices](/docs/halo/concepts/identities-devices/) describes the model in full.
## The finished app
Here it runs for Alice, Bob and Carol. All three are in Book club, and Alice and Carol are also in Climbing. Open Contacts
on Alice's device to see Bob and Carol with the spaces each shares with her. Make a new space, invite someone into it, or
switch a device offline. To change the code and run it yourself, fork it on StackBlitz.
Each page of the learning path builds one piece of it:
| Page | Files | What it adds |
| --- | --- | --- |
| [Your profile](/docs/halo/learn/your-profile/) | `App.tsx`, `Welcome.tsx`, `ContactBook.tsx`, `Profile.tsx` | A client, an identity with a profile, and renaming |
| [Your spaces](/docs/halo/learn/your-spaces/) | `Spaces.tsx`, `ContactBook.tsx` | Named spaces, and a list to switch between them |
| [Invite people](/docs/halo/learn/invite-people/) | `Invitations.tsx` | Invite and Join, with an authorization code |
| [Your contacts](/docs/halo/learn/your-contacts/) | `Contacts.tsx` | Everyone you share a space with, and removing them |
| [Add a device](/docs/halo/learn/add-device/) | `Devices.tsx`, `Welcome.tsx` | Your phone, with the same name, spaces and contacts |
| [Recover an identity](/docs/halo/learn/recover-identity/) | `Recovery.tsx`, `Welcome.tsx` | A recovery code, and your identity back on a new device |
The code is Effect. Each change is an `Effect` that needs the HALO services. A `HaloProvider` from `@dxos/halo-react`
holds each device's services. A component reads state with that package's hooks, such as `useIdentity` and `useSpaces`,
and runs a change with `useHaloServices`, `Effect.provide` and `Effect.runPromise`. Each piece sits in the component that uses it; the samples show it there, with the rest of the
component folded away.
---
# Your profile
Create an identity with a profile, show it and change it.
By the end of this page the contact book will create your identity with a profile the first time it runs, show your
name from it in the corner, and let you change it.
To write the code as you read, open the starter on StackBlitz. It is the finished app with the code from these pages taken
out. It runs from the start, and when the app reaches code you have not written yet, a line at the top names the step and
its file. Each sample on these pages is titled with the file it goes in, and numbered as that file is.
## Background
An identity stands for you. In Keyhive, which HALO is built on, it is a group named by a DID, such as
`did:halo:BA25QRC2…`. Its members are your devices, and the first member is the device that creates it. No server issues
the identity; the client makes it on the device, with no network needed.
The identity also has a profile: a display name, and any other data the app wants, such as an avatar. Everyone you share a
space with sees it.
The code is Effect. `@dxos/halo` describes each operation as an `Effect` or a `Stream` that needs `Identity.Service` or
`Space.Service`. `@dxos/halo-adapter-client` makes both from a client, and `@dxos/halo-react` hands them to the
components through React context, with hooks that follow the streams.
## Steps
1. **Install the packages.** `@dxos/client` holds the client, `@dxos/halo` the API these pages use,
`@dxos/halo-adapter-client` the services between them, and `@dxos/halo-react` the provider for React. Until the next npm release, install the build of main these pages
describe, from pkg.pr.new.
```bash
pnpm add https://pkg.pr.new/dxos/dxos/@dxos/client@20a14b9 https://pkg.pr.new/dxos/dxos/@dxos/halo@20a14b9 https://pkg.pr.new/dxos/dxos/@dxos/halo-adapter-client@20a14b9 https://pkg.pr.new/dxos/dxos/@dxos/halo-react@20a14b9 effect@4.0.4
```
```bash
npm install --save https://pkg.pr.new/dxos/dxos/@dxos/client@20a14b9 https://pkg.pr.new/dxos/dxos/@dxos/halo@20a14b9 https://pkg.pr.new/dxos/dxos/@dxos/halo-adapter-client@20a14b9 https://pkg.pr.new/dxos/dxos/@dxos/halo-react@20a14b9 effect@4.0.4
```
The browser setup is the same as ECHO's; see [Installation](/docs/echo/learn/installation/) for the Vite config. The
StackBlitz project has it already.
2. **Configure the client.** The client keeps the device's keys and data on the device, and reaches other devices through
EDGE, DXOS's network, which relays invitations and replicates spaces.
```ts
import { Config } from '@dxos/config';
/**
* Keeps the app's keys and data on the device and reaches other devices
* through DXOS's EDGE network, which relays invitations and replicates spaces.
*/
const config = new Config({
runtime: {
client: {
storage: { persistent: true },
edgeFeatures: {
signaling: true,
subductionReplicator: true,
feedReplicator: true,
},
},
services: {
edge: { url: 'https://dxos.network/' },
iceProviders: [{ urls: 'https://dxos.network/ice' }],
},
},
});
```
3. **Start the client.** `openHalo` starts a client and returns it with the HALO services it backs. The app calls it
once, at startup, and puts the services in a `HaloProvider`. Below it, a component gets them with `useHaloServices()`
and runs each piece of HALO code with `Effect.provide(services)` and `Effect.runPromise`.
```ts
import * as Context from 'effect/Context';
import { Client, type ClientServicesProvider } from '@dxos/client';
import { Identity, Space } from '@dxos/halo';
import { makeIdentityService, makeSpaceService } from '@dxos/halo-adapter-client';
/** Starts a client, and returns it with the HALO services it backs. */
export const openHalo = async (clientServices?: ClientServicesProvider) => {
const client = new Client(
clientServices
? { services: clientServices }
: {
config,
// Keeps the device's keys and data in the browser's private file system.
createOpfsWorker: () =>
new Worker(new URL('@dxos/client/opfs-worker', import.meta.url), {
type: 'module',
}),
},
);
await client.initialize();
const services = Context.empty().pipe(
Context.add(Identity.Service, makeIdentityService(client)),
Context.add(Space.Service, makeSpaceService(client)),
);
return { client, services };
};
```
4. **Create your identity.** `Identity.create` makes the identity, with this device as its first member, and gives its
profile your name. **Start** on the welcome screen calls this.
```ts
/** Creates the identity, with this device as its first member. */
const handleStart = (name: string) =>
Identity.create({ displayName: name }).pipe(
Effect.provide(services),
Effect.runPromise,
);
```
5. **Follow your identity.** `useIdentity` returns the identity, or `undefined` before there is one, and re-renders the
component on every change. It follows `Identity.identity`, a stream of the identity. The contact book reads it to show
either the welcome screen or your name.
```ts
/** The identity, re-rendering on each change; undefined before it exists. */
const identity = useIdentity();
```
6. **Change your profile.** `Identity.updateProfile` writes a new profile. `useIdentity` re-renders with it, and so does
every space member list that includes you. Clicking your name, then **Save**, calls this.
```ts
/** Changes the name everyone who shares a space with you sees. */
const handleRename = (name: string) =>
Identity.updateProfile({ displayName: name }).pipe(
Effect.provide(services),
Effect.runPromise,
);
```
## Try it
This device has no identity yet. Type a name and press **Start**, then click your name to change it.
## What you can do now
You can start a client, create an identity with a profile, follow it from a component, and change its profile. Next,
[Your spaces](/docs/halo/learn/your-spaces/) gives it somewhere to invite people.
---
# Your spaces
Create named spaces and switch between them.
By the end of this page you can make named spaces, such as Book club and Climbing, and switch between them.
## Background
A space is a group of identities that replicate the same data. ECHO keeps a database in each one; the contact book uses
spaces only for who is in them. In Keyhive terms a space is a group, and the identity that creates it is its first admin.
Admins invite people, change what members may do and remove them.
`Space.create` makes a space, and `Space.spaces` lists every space your identity belongs to. Your identity also has a
personal space, which only your own devices join; `Space.spaces` leaves it out.
## Steps
1. **Create a space.** `Space.create` returns the new space's id, name and state at once. The space opens a moment later,
so wait for `Space.waitReady` before you use it, then select it. **New space** calls this.
```ts
/** Creates a space called `name`, waits until it opens, and selects it. */
const handleCreate = (name: string) =>
Effect.gen(function* () {
const space = yield* Space.create({ name });
yield* Space.waitReady(space.id);
return space;
})
.pipe(Effect.provide(services), Effect.runPromise)
.then((space) => onSelect(space.id));
```
2. **List your spaces.** `useSpaces` returns the spaces that are ready, and re-renders when one is created, joined or
left. It follows `Space.spaces`, which also lists spaces that are not open. The dropdown shows the named ones, by name.
```ts
/** The spaces that are open, re-rendering on each change. */
const spaces = useSpaces();
/** The named ones, by name. */
const named = useMemo(
() =>
spaces
.filter((space) => space.name)
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')),
[spaces],
);
```
## Try it
Alice has a name and no spaces. Press **New space**, name it, and make another; the dropdown switches between them.
## What you can do now
You can create spaces and list them in a component. Next, [Invite people](/docs/halo/learn/invite-people/) lets someone
else in.
---
# Invite people
Alice invites Bob into a space with an invitation code and an authorization code.
By the end of this page Alice can invite Bob into Book club, and Bob joins with two codes: one she sends him and one she
tells him.
## Background
An invitation takes two codes, which travel by different routes. The invitation code is long and goes in a message or a
link. The authorization code is six digits, which the host sees once the guest connects and passes on another way, such
as reading it aloud. Someone who intercepts only the message cannot get in.
When the guest enters the right code, the two sides exchange contact cards: each names its identity and carries the keys
the other needs. Then the host writes a delegation, a statement signed by a member allowed to make it, that admits the
guest. Every member checks that delegation for itself, so no server has to vouch for the guest.
An invitation is a `Flow`. Its events say where the handshake is: `connecting`, `connected`, `readyForAuthentication`,
`authenticating`, then `success`, `cancelled` or `error`. [Invitations](/docs/halo/concepts/invitations/) covers the rest.
## Steps
1. **Invite someone.** `Space.share` starts the invitation on the host, and `Invitation.code` is the code to send.
`shared-secret` makes the guest enter the authorization code. **Invite** calls this and shows the code.
```ts
/** Starts an invitation to the space, and returns the flow and the code to send. */
const handleInvite = (spaceId: SpaceId) =>
Effect.gen(function* () {
const flow = yield* Space.share(spaceId, {
authMethod: 'shared-secret',
});
const code = yield* Invitation.code(flow);
return { flow, code };
}).pipe(Effect.provide(services), Effect.runPromise);
```
2. **Join.** On the guest, `Space.join` redeems the code and returns the guest's own flow. **Join** calls this.
```ts
/** Redeems the invitation `code`, and returns the flow. */
const handleJoin = (code: string) =>
Space.join(code).pipe(Effect.provide(services), Effect.runPromise);
```
3. **Follow the handshake.** `useInvitationFlow` returns the flow's latest event and re-renders on each one, until the
flow ends. Both sides follow their flow with it. On the host, `readyForAuthentication`
carries the authorization code to show; on the guest, it is the moment to ask for it.
```ts
/** The flow's latest event, re-rendering as the handshake moves on. */
const { event } = useInvitationFlow(flow?.flow);
```
4. **Send the authorization code.** On the guest, `Invitation.authenticate` sends the code the host showed. **Send**
calls this.
```ts
/** Sends the authorization code the host shows. */
const handleAuthenticate = (authCode: string) =>
Invitation.authenticate(flow, authCode.trim()).pipe(
Effect.provide(services),
Effect.runPromise,
);
```
## Try it
Alice has Book club; Bob has nothing yet.
1. On Alice's device, press **Invite** and copy the code.
2. On Bob's, press **Join**, paste it and press **Join**.
3. Alice's device now shows an authorization code. Type it into Bob's and press **Send**.
Bob's dropdown now has Book club, and both devices list Alice and Bob under People. Rename one of them and watch the
other follow.
## What you can do now
You can invite someone into a space and join one, with an authorization code. Next,
[Your contacts](/docs/halo/learn/your-contacts/) gathers everyone you share a space with.
---
# Your contacts
List everyone you share a space with, and remove someone from a space.
By the end of this page the contact book lists everyone you share a space with, once each, with the spaces you share,
and you can remove someone from a space.
## Background
A contact is anyone who shares a space with you. The contact book keeps no list of its own: it reads each space's members
and merges them by DID, since a member's DID is the same in every space.
A member is an identity with an access level: `pull`, `read`, `edit` or `admin`. Removing a member is a revocation, the
signed opposite of the delegation that let them in. The space's group key changes with it, so they cannot read anything
written after. An admin can remove anyone; changing a member's access instead is on the
[Space](/docs/halo/api/space/) API page.
## Steps
1. **List your contacts.** `useContacts` returns everyone who shares a space with you, once each, with the spaces you
share. It follows `Space.spaces` and each space's `Space.members`, leaves you out, and re-renders when someone joins,
leaves or comes online. The Contacts tab shows a row for each contact.
```ts
/** Everyone you share a space with, re-rendering on each change. */
const contacts = useContacts();
```
2. **Remove someone from a space.** `Space.removeMember` revokes their membership. The **×** on a space beside a contact calls this, and
that space leaves their row.
```ts
/** Removes `contact` from `space`. */
const handleRemove = (contact: Contact, space: Space.Info) =>
Space.removeMember(space.id, contact.did).pipe(
Effect.provide(services),
Effect.runPromise,
);
```
## Try it
Alice, Bob and Carol are all in Book club, and Alice and Carol are in Climbing. Open Contacts on any device: Alice sees
Bob with Book club, and Carol with Book club and Climbing. Remove Carol from Climbing on Alice's device, and Climbing leaves
her row.
## What you can do now
You can list everyone you share a space with and remove someone from a space. Next, [Add a device](/docs/halo/learn/add-device/)
puts the same contacts on your phone.
---
# Add a device
Join Alice's phone to her identity, so it shows the same contacts.
By the end of this page Alice's phone joins her identity, and shows her name, her spaces and her contacts.
## Background
Each device has its own key. Adding a device is the same handshake as inviting a person, with a device invitation
instead of a space invitation: the device already in the identity is the host, and the new device the guest. At the end,
the host writes a delegation that makes the new device a member of the identity's group.
The new device then syncs the identity's personal space and every space the identity is in, from the other device or
through EDGE. Nothing about the identity has to be typed in again.
## Steps
1. **Share your identity.** `Identity.share` starts a device invitation on the device you have. **Add a device** on the
Devices tab calls this and shows the code.
```ts
/** Starts an invitation for another device of yours, and returns the flow and the code to open there. */
const handleAdd = () =>
Effect.gen(function* () {
const flow = yield* Identity.share({ authMethod: 'shared-secret' });
const code = yield* Invitation.code(flow);
return { flow, code };
}).pipe(Effect.provide(services), Effect.runPromise);
```
2. **Join from the new device.** On the new device, before it has an identity, `Identity.join` redeems the code. The
handshake and its authorization code are the same as for a space, so the welcome screen hands the flow to the same
guest panel as **Join**. **Join your other device** calls this.
```ts
/** Redeems the invitation `code` from your other device, and returns the flow. */
const handleJoin = (code: string) =>
Identity.join(code).pipe(Effect.provide(services), Effect.runPromise);
```
3. **List your devices.** `useDevices` returns the identity's devices and re-renders when one joins or comes online. It
follows `Identity.devices`. Each device has a key, a kind, whether it is online, and whether it is the device the code
runs on. The Devices tab shows them.
```ts
/** Your devices, re-rendering on each change. */
const devices = useDevices();
```
## Try it
Alice's laptop has her identity, her spaces and her contacts; her phone has nothing.
1. On the laptop, open **Devices**, press **Add a device** and copy the code.
2. On the phone, press **Join your other device**, paste it and press **Join**.
3. Type the authorization code the laptop shows into the phone and press **Send**.
The phone now shows Alice's name, both spaces and both contacts, and each device lists the other.
## What you can do now
You can add a device to your identity and list your devices. Next,
[Recover an identity](/docs/halo/learn/recover-identity/) covers losing all of them.
---
# Recover an identity
Make a recovery code, and get Alice's identity back on a new laptop.
By the end of this page Alice has a recovery code, and with it gets her name, spaces and contacts back on a new laptop.
Recovery needs a DXOS account. EDGE stores recovery credentials only for identities linked to an account, and for now
you can create an account only through [Composer](https://composer.space).
## Background
If every device is lost, the identity is lost with them, unless it has a recovery credential: a key kept outside every
device. A recovery code is one such key, written as twelve words. Making it adds the key to the identity's group as a
member, the same way a device is added, so the key can admit a new device later.
Recovery goes through EDGE. The new device asks EDGE for a challenge, signs it with the key derived from the words, and
EDGE checks the signature against the identity's recovery credentials before it admits the device. The new device is a
new member of the same identity, with the same DID, and syncs its spaces from EDGE and from anyone online.
HALO shows a recovery code once and keeps only its public half, and it will not revoke the last code you have: make the
new one first. A passkey works the same way; see [Passkey recovery](/docs/halo/guides/passkey-recovery/).
## Steps
1. **Make a recovery code.** `Identity.createRecoveryCredential` makes the key, adds it to the identity, and returns the
words. **Make a recovery code** on the Recovery tab calls this.
```ts
/** Makes a recovery code, and shows it. HALO returns it only this once. */
const handleCreate = () =>
Effect.gen(function* () {
const { recoveryCode } = yield* Identity.createRecoveryCredential();
return recoveryCode;
})
.pipe(Effect.provide(services), Effect.runPromise)
.then((code) => {
setError(undefined);
setCreated(code);
});
```
2. **List your codes.** `useCredentials` returns the identity's credentials and re-renders when one is written. It
follows `Identity.credentials`. A recovery credential carries
its kind, a label, the date it was made, a lookup key and whether it was revoked. The Recovery tab lists the ones still
active.
```ts
/** The recovery credentials that still work, re-rendering on each change. */
const codes = useCredentials().flatMap(({ recovery, issuanceDate }) =>
recovery && !recovery.revoked ? [{ ...recovery, issuanceDate }] : [],
);
```
3. **Revoke a code.** `Identity.revokeRecoveryCredential` takes the lookup key. Revoke a code you think someone has seen.
```ts
/** Revokes the recovery credential with `lookupKey`. */
const handleRevoke = (lookupKey: string) =>
Identity.revokeRecoveryCredential(lookupKey)
.pipe(Effect.provide(services), Effect.runPromise)
.then(
() => setError(undefined),
(error) =>
setError(
codes.length === 1
? 'HALO will not revoke your last recovery code. Make another one first.'
: String(error),
),
);
```
4. **Recover on a new device.** On a device with no identity, `Identity.recover` takes the words and gets the identity
back. **Recover with a code** calls this.
```ts
/** Gets the identity back with `recoveryCode`. */
const handleRecover = (recoveryCode: string) =>
Identity.recover({ recoveryCode: recoveryCode.trim() }).pipe(
Effect.provide(services),
Effect.runPromise,
);
```
## Try it
Open **Recovery** on Alice's laptop and make a code. Make a second one, then revoke the first. Try revoking the last one
too, and HALO refuses.
The devices on these pages run in your browser with no network, and recovering needs EDGE. To recover, fork the
finished app on StackBlitz, which runs on the real network: make a code there, then open the project in a private
window, choose **Recover with a code** and enter the words.
## What you can do now
You can make and revoke recovery codes, and recover an identity on a new device. That is the whole contact book. From
here, [Core concepts](/docs/halo/concepts/identities-devices/) explains the model behind it, and the
[API](/docs/halo/api/identity/) pages cover the rest of `@dxos/halo`.
---
# Identities and devices
An identity is a Keyhive group named by a DID; its devices are its members.
An identity stands for one user. In [Keyhive](https://www.inkandswitch.com/keyhive/notebook/), which HALO is built on, it
is a group named by a DID, and its members are the user's devices.
## Model
**Identity.** `Identity.create` makes the group and its first member, the device the code runs on. The DID, such as
`did:halo:BA25QRC2FEWCSAMRP4RZL65LWJ7352CKE`, is how other members and services refer to the user. The identity also has a
profile: a display name and free-form data such as an avatar. No server issues the identity, and none holds a key that can
act for it.
**Device.** Each client storage, such as one browser profile on one site, has its own device key. A device becomes a
member of the identity through a delegation: a statement, signed by a device that is already a member, that admits the new
key. Removing a device is a revocation. The first device creates the identity; later devices join through a
[device invitation](/docs/halo/concepts/invitations/). A device signs the changes it makes, and peers check the signature
against the identity's members.
Each device reports:
| Field | Meaning |
| ---------- | ----------------------------------------------------------------------- |
| `kind` | `browser`, `native`, `mobile`, or `agent` and `agent-managed` for hosted agents |
| `presence` | `online`, `offline`, or `removed` once the device has been evicted |
| `current` | Whether this is the device the code runs on |
**Personal space.** Every identity has a personal space that only its own devices join. It holds the user's settings and
anything that follows them from device to device. `Identity.personalSpaceId` returns its id, or nothing before the
identity exists.
```text
identity (did:halo:…)
├── device: laptop browser ← created the identity
├── device: phone ← joined by invitation
└── personal space ← replicated to every device
```
## Behavior
- Creating an identity needs no network.
- A new device receives the identity's spaces by syncing with the devices it was admitted by, or through EDGE.
- A device that is revoked stops being trusted for new changes. Changes it signed before the revocation stay valid.
## Limits
- If every device is lost and the identity has no [recovery credential](/docs/halo/concepts/recovery-credentials/), the
identity is gone. No one, including DXOS, can restore it, and its spaces are reachable only through other members.
A [recovery key](/docs/halo/concepts/recovery-credentials/) is a member of the identity too, so it can admit a new device
when no device is left.
- Clearing a browser's site data deletes that device's keys. The device must join again as a new device.
---
# Spaces and membership
Who may enter a space, and what each member may do.
A space's membership is a Keyhive group: the identities that may replicate the space, each with an access level.
[Spaces and databases](/docs/echo/concepts/spaces/) covers the data in a space and how it replicates; this page covers who
gets it.
## Model
**Members.** Each member is an identity, named by its DID, with one access level. The levels are ordered, and each includes
the one before:
| Access | The member can |
| ------- | ---------------------------------------------------------- |
| `pull` | Receive the space's data in encrypted form, for example a sync service |
| `read` | Read the space |
| `edit` | Read and change the space |
| `admin` | Change membership: invite, change roles, remove members |
The identity that creates a space is its first admin. Levels come from [Keyhive](https://www.inkandswitch.com/keyhive/notebook/).
**Membership policy.** `invite` admits new members through [invitations](/docs/halo/concepts/invitations/). `locked` admits
no one new, which suits a space that should stay private to one person or a fixed group.
**How access is checked.** Membership is a graph of signed delegations and revocations. Each delegation, such as "admit
this identity with `edit`", is signed by a member allowed to make it, and no member can delegate more access than it
holds. Every peer keeps the graph and checks each operation against the members it already trusts, so a peer decides
whether to accept another peer's changes without asking a server. Delegations and revocations converge: peers that have
seen the same ones agree on the membership, whatever order they arrived in.
**Who can revoke whom.** An admin can revoke any member. Any other member can revoke only members it has causal seniority
over: members whose delegation depends on its own, because it admitted them or admitted whoever did. A revocation that breaks this rule fails
with `NotAuthorizedError`.
**Encryption.** Keyhive keeps a group key that the current members share. A space's changes are encrypted with it, so a
member with `pull` stores and relays the data without reading it. When membership changes, the key changes too.
**EDGE.** EDGE, DXOS's sync service, joins a space as a `pull` member. It holds and relays the encrypted changes, so
devices that are never online together still sync, and it cannot read them. A service that works on plaintext, such as
AI or search, needs `read`, which an admin grants explicitly.
## Behavior
- An admin can invite members, change a member's access with `Space.updateMemberRole` and remove a member with
`Space.removeMember`.
- Peers reject changes from an identity whose access does not allow them.
- Removing a member is a revocation, and it rotates the space's key. They stop receiving new changes, and could not
decrypt them if they did.
## Limits
- Removal does not reach data the member already has. A removed member keeps their copy of everything they could read
before.
- Access applies to the whole space. To share part of the data with fewer people, put it in a separate space.
---
# Invitations
How a device or a person is let into an identity or a space.
An invitation is a short-lived offer from a host to let one guest in: a new device into an identity, or a new member into
a space. It ends in a delegation, the same signed statement that every other change to membership is.
## Model
**Kinds.** A `device` invitation admits a device to the host's identity. A `space` invitation admits another identity to a
space. `Identity.share` makes the first and `Space.share` the second.
**Types.**
| Type | Behavior |
| ------------- | -------------------------------------------------------------- |
| `interactive` | Host and guest are online at the same time and talk directly |
| `delegated` | The guest can redeem it while the host is offline |
| `multiuse` | More than one guest can redeem it |
**Auth methods.** How the guest proves it is the intended guest:
| Auth method | Proof |
| ------------------ | ---------------------------------------------------------------- |
| `none` | None. Anyone with the invitation code is admitted |
| `shared-secret` | A short authorization code the host shows, typed in by the guest |
| `known-public-key` | A signature from a key the host named in advance |
The invitation code and the authorization code travel by different routes, for example a link in a chat and a code read
aloud. Someone who intercepts only the link cannot get in.
**Admission.** Two things happen when a guest is let in. The two sides exchange contact cards: each card names an identity
or device and carries the keys others need to encrypt to it and check its signatures. Then the host writes a
[delegation](/docs/halo/concepts/spaces-membership/) that admits the guest, signed by a member allowed to make it. Peers
check that delegation like any other, so nothing about the invitation itself has to be trusted after it ends.
## The flow
`Invitation.events` follows a flow from either side. An interactive invitation with `shared-secret` runs like this:
1. The host calls `share`. The flow emits `connecting` and waits for a guest.
2. The host sends the invitation code to the guest.
3. The guest calls `join` with the code. The two find each other through the signaling service and open an encrypted
connection. Both flows emit `connected`.
4. Both flows emit `readyForAuthentication`. The host's event carries the authorization code, which the host passes to
the guest by another route.
5. The guest calls `Invitation.authenticate` with the code. Both flows emit `authenticating`.
6. The host checks the code. The two sides exchange contact cards, and the host writes the delegation that admits the
guest. Both flows emit `success`; the guest's result names the space or identity it joined.
Any step can end in `cancelled`, if either side calls `Invitation.cancel`, or `error`.
## Delegated invitations
A delegated invitation works while the host is offline. The host publishes prekeys in advance: one-time keys the guest can
encrypt to without the host there. The host signs the operations that admit whoever redeems the invitation, and EDGE holds
them until the guest connects. The guest then reads the operations, checks the signatures, and joins with the access the
host chose.
## Limits
- An invitation that is never redeemed expires. Start a new one.
- A wrong authorization code fails the attempt with an `error` event, and the guest must join again.
- `none` admits anyone who sees the code. Use it for demos and for codes you show only on a screen you control.
---
# Recovery and credentials
How an identity survives the loss of its devices, and what credentials it holds.
A recovery credential is a key outside every device that the identity has admitted as one of its members. It can admit a
new device, so the user gets their identity back after losing all of them.
Recovery needs a DXOS account. EDGE stores recovery credentials only for identities linked to an account, and for now
you can create an account only through [Composer](https://composer.space).
## Model
**Recovery kinds.**
| Kind | The key lives |
| --------------- | ----------------------------------------------------------- |
| `recovery-code` | In a phrase of words HALO generates and the user writes down |
| `passkey` | In the platform's passkey store, behind the user's biometrics or PIN |
| `oauth` | Behind an account with an OAuth provider |
**The recovery key is a member.** Creating a recovery credential is a delegation, like admitting a device: the identity's
group gains a member whose key the user holds outside every device. Revoking the credential removes that member.
**The recovery challenge.** Recovery runs through EDGE. The new device asks for a challenge with
`Identity.requestRecoveryChallenge`, signs it with the recovery key, and sends the signature back with `Identity.recover`.
For a recovery code, `recover` derives the key from the phrase and does the signing itself. EDGE checks the signature
against the identity's recovery credentials and admits the new device.
**What comes back.** The recovered device has the same identity and DID. It is a new device of that identity, so it syncs
the personal space and the identity's other spaces from EDGE and from any other members.
**Credentials.** An identity holds signed credentials: the statements that admit its devices, its recovery credentials,
and service access credentials. `Identity.credentials` is a stream of them, with the current set first. A recovery
credential carries its kind, its label, its lookup key and whether it has been revoked. `grantServiceAccess` writes a credential that lets the
identity use an EDGE or Hub service, and `createApiToken` mints a token for a CLI or a server to act as the identity.
## Behavior
- Creating a recovery code shows the phrase once. HALO keeps only the public half.
- A revoked recovery credential no longer admits devices.
- HALO refuses to revoke the last recovery credential that is still active. Make the new one first.
- Recovery needs a network connection to EDGE.
## Limits
- A lost recovery code, with every device lost too, means the identity is gone. There is no reset.
- Anyone who holds a recovery code can take over the identity. Revoke a code you think has leaked, then make a new one.
---
# Passkey recovery
Register a passkey as a recovery credential and recover with it.
A passkey can be an identity's recovery credential. The user then gets their identity back on a new device with a
fingerprint or a PIN, with no recovery code to keep. `@dxos/halo` writes and checks the credential; the WebAuthn ceremony
that creates and uses the passkey runs in the app, because only the app knows its site and its UI.
This guide uses the browser's WebAuthn API. Recovery goes through EDGE, so the client needs a network connection.
Recovery needs a DXOS account. EDGE stores recovery credentials only for identities linked to an account, and for now
you can create an account only through [Composer](https://composer.space).
## Register a passkey
`navigator.credentials.create` makes the passkey. Put a random lookup key in its user handle: the browser returns the
handle when the passkey signs, and HALO finds the credential by it. Then pass the passkey's public key, the lookup key and
the algorithm to `Identity.createRecoveryCredential` as `externalKey`. The identity's group gains the passkey as a member.
```ts
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { Identity } from '@dxos/halo';
import { PublicKey } from '@dxos/keys';
const registerPasskey = Effect.gen(function* () {
const identity = Option.getOrThrow(yield* Identity.getSnapshot);
// The lookup key names the passkey. It goes in the user handle, so the
// assertion returns it.
const lookupKey = PublicKey.random();
const credential = yield* Effect.promise(() =>
navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(32),
rp: { id: rpId, name: 'Contact book' },
user: {
id: new Uint8Array(lookupKey.asUint8Array()),
name: identity.did,
displayName: identity.displayName ?? '',
},
pubKeyCredParams: [
{ type: 'public-key', alg: -8 }, // Ed25519
{ type: 'public-key', alg: -7 }, // ES256
],
authenticatorSelection: { residentKey: 'required' },
},
}),
);
if (
!(credential instanceof PublicKeyCredential) ||
!(credential.response instanceof AuthenticatorAttestationResponse)
) {
return yield* Effect.die('No passkey was created.');
}
const response = credential.response;
const publicKey = response.getPublicKey();
if (!publicKey) {
return yield* Effect.die('The authenticator returned no public key.');
}
yield* Identity.createRecoveryCredential({
externalKey: {
recoveryKey: PublicKey.from(new Uint8Array(publicKey)).toHex(),
lookupKey: lookupKey.toHex(),
algorithm: response.getPublicKeyAlgorithm() === -7 ? 'ES256' : 'ED25519',
label: 'Passkey',
kind: 'passkey',
},
});
});
```
The passkey must be a resident key (`residentKey: 'required'`), so a new device can find it without being told which one
to use.
## Recover on a new device
On the new device, before any identity exists:
1. `Identity.requestRecoveryChallenge` gets a challenge from EDGE for this device.
2. `navigator.credentials.get` asks the user to sign it with the passkey.
3. `Identity.recover` sends the signature, the challenge and the lookup key from the user handle. EDGE checks them against
the identity's recovery credentials and admits the device.
```ts
import * as Effect from 'effect/Effect';
import { Identity } from '@dxos/halo';
import { PublicKey } from '@dxos/keys';
const fromBase64 = (value: string) =>
Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
const recoverWithPasskey = Effect.gen(function* () {
const challenge = yield* Identity.requestRecoveryChallenge;
const credential = yield* Effect.promise(() =>
navigator.credentials.get({
publicKey: {
challenge: fromBase64(challenge.challenge),
rpId,
userVerification: 'required',
},
}),
);
if (
!(credential instanceof PublicKeyCredential) ||
!(credential.response instanceof AuthenticatorAssertionResponse)
) {
return yield* Effect.die('No passkey assertion was returned.');
}
const response = credential.response;
// The user handle carries the lookup key the passkey was registered with.
const userHandle = response.userHandle;
if (!userHandle) {
return yield* Effect.die('The passkey has no user handle.');
}
return yield* Identity.recover({
passkey: {
challenge,
lookupKey: PublicKey.from(new Uint8Array(userHandle)).toHex(),
signature: new Uint8Array(response.signature),
clientDataJson: new Uint8Array(response.clientDataJSON),
authenticatorData: new Uint8Array(response.authenticatorData),
},
});
});
```
`recover` returns the identity. The device then syncs the identity's spaces, as any new device does.
## Revoke a passkey
`Identity.credentials` lists the recovery credentials, each with its kind and lookup key. `revokeRecoveryCredential`
removes one. HALO refuses to revoke the last active recovery credential, so register the replacement first.
```ts
import * as Effect from 'effect/Effect';
import * as Stream from 'effect/Stream';
import { Identity } from '@dxos/halo';
const revokePasskeys = Effect.gen(function* () {
const [credentials] = yield* Identity.credentials.pipe(
Stream.take(1),
Stream.runCollect,
);
for (const { recovery } of credentials) {
if (
recovery?.kind === 'passkey' &&
!recovery.revoked &&
recovery.lookupKey
) {
yield* Identity.revokeRecoveryCredential(recovery.lookupKey);
}
}
});
```
## Related
- [Recovery and credentials](/docs/halo/concepts/recovery-credentials/)
- [Recover an identity](/docs/halo/learn/recover-identity/), which uses a recovery code.
- [Identity](/docs/halo/api/identity/)
---
# React
Read the identity, devices, spaces and members from React components.
```ts
import { HaloProvider, useIdentity } from '@dxos/halo-react';
```
`@dxos/halo-react` reads HALO from React components. Each hook follows one `@dxos/halo` stream and re-renders when it
emits, so a component shows the current identity, devices or members without subscribing itself.
## Set up the provider
`HaloProvider` takes the HALO services as an Effect `Context`. Build it from a `Client` with the adapter's
`makeIdentityService` and `makeSpaceService`, and wrap the part of the app that uses the hooks.
```ts
import * as Context from 'effect/Context';
import { Identity, Space } from '@dxos/halo';
import { makeIdentityService, makeSpaceService } from '@dxos/halo-adapter-client';
import { HaloProvider } from '@dxos/halo-react';
const services = Context.empty().pipe(
Context.add(Identity.Service, makeIdentityService(client)),
Context.add(Space.Service, makeSpaceService(client)),
);
export const App = () => (
);
```
## The identity
`useIdentity` returns the identity, or `undefined` before one exists. It has the current value on the first render.
`useDevices` and `useCredentials` return the identity's devices and credentials.
```ts
import { useCredentials, useDevices, useIdentity } from '@dxos/halo-react';
export const Profile = () => {
const identity = useIdentity();
const devices = useDevices();
const credentials = useCredentials();
if (!identity) {
return
{identity.displayName} on {devices.length} devices, with {recovery.length}{' '}
recovery credentials.
);
};
```
## Spaces and members
`useSpaces` returns the spaces that are ready; pass `{ all: true }` for every space. `useSpace` finds one by id, and
`useMembers` returns a space's members.
```ts
import { useMembers, useSpace, useSpaces } from '@dxos/halo-react';
import { type SpaceId } from '@dxos/keys';
export const SpaceList = () => {
const spaces = useSpaces();
return (
{spaces.map((space) => (
))}
);
};
export const Members = ({ spaceId }: { spaceId: SpaceId }) => {
const space = useSpace(spaceId);
const members = useMembers(spaceId);
return (
<>
{space?.name}:{' '}
{members.map((member) => member.displayName ?? member.did).join(', ')}
>
);
};
```
## Invitations
Start a flow with `Space.share` or `Identity.share`, run as an Effect with the same services. `useInvitationFlow` follows
the flow: its latest event and its code. It needs no provider, since a flow carries what it needs. `useSpaceInvitations`
and `useHaloInvitations` list the flows this device has open.
```ts
import * as Effect from 'effect/Effect';
import { useState } from 'react';
import { Invitation, Space } from '@dxos/halo';
import { useHaloInvitations, useInvitationFlow, useSpaceInvitations } from '@dxos/halo-react';
import { type SpaceId } from '@dxos/keys';
export const Invite = ({ spaceId }: { spaceId: SpaceId }) => {
const [flow, setFlow] = useState();
const { event, code } = useInvitationFlow(flow);
const share = () =>
Effect.runPromise(
Space.share(spaceId, { authMethod: 'shared-secret' }).pipe(
Effect.provide(services),
),
).then(setFlow);
if (!flow) {
return ;
}
return (
);
};
// Every flow this device has open, for a space or for the identity.
export const OpenInvitations = ({ spaceId }: { spaceId: SpaceId }) => {
const forSpace = useSpaceInvitations(spaceId);
const forDevices = useHaloInvitations();
return (
{forSpace.length} space and {forDevices.length} device invitations open.
);
};
```
## Hooks at a glance
| Hook | Returns |
| ------------------------------------------ | --------------------------------------------- |
| `useIdentity` | The identity, or `undefined` |
| `useDevices` `useCredentials` | The identity's devices and credentials |
| `useSpaces` `useSpace` | Spaces, and one space by id |
| `useMembers` | A space's members |
| `useContacts` | Everyone you share a space with, once each |
| `useSpaceInvitations` `useHaloInvitations` | Open space and device invitations |
| `useInvitationFlow` | One flow's latest event and code |
## Related
- [Identity](/docs/halo/api/identity/), [Space](/docs/halo/api/space/) and [Invitation](/docs/halo/api/invitation/)
- The [contact book](/docs/halo/learn/the-halo-identity/) in the learning path is built on `HaloProvider` and these
hooks.
---
# Identity
Create, follow, recover and share an identity.
```ts
import { Identity } from '@dxos/halo';
```
`Identity` manages the user on this device: the identity itself, its profile, its devices, its recovery credentials and
the credentials it grants to services. Every function is an Effect or a Stream that needs `Identity.Service`. Run one with
`Effect.runPromise` once the service is provided.
## Get the service
`layerClient` from `@dxos/halo-adapter-client` provides `Identity.Service` and `Space.Service` from a `Client`.
```ts
import { Client } from '@dxos/client';
import { Identity } from '@dxos/halo';
import { layerClient } from '@dxos/halo-adapter-client';
const client = new Client();
await client.initialize();
// Provides Identity.Service and Space.Service.
const halo = layerClient(client);
```
## Read the identity
`getSnapshot` reads it once, as an `Option` that is empty before an identity exists. `identity` is a stream that emits the
current value, then each change.
```ts
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import * as Stream from 'effect/Stream';
import { Identity } from '@dxos/halo';
const whoAmI = Effect.gen(function* () {
const identity = yield* Identity.getSnapshot;
return Option.map(identity, (identity) => identity.displayName);
});
const watch = Identity.identity.pipe(
Stream.runForEach((identity) =>
Effect.sync(() =>
console.log(
Option.match(identity, {
onNone: () => 'no identity yet',
onSome: (identity) => identity.displayName,
}),
),
),
),
);
```
Code outside Effect can take the service itself and use its synchronous methods: `subscribe` calls back with each change
and returns a function that stops it. `Identity.atom` gives the same value as an Atom.
```ts
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { Identity } from '@dxos/halo';
// The synchronous methods, for code outside Effect.
const service = Effect.gen(function* () {
const identity = yield* Identity.Service;
const unsubscribe = identity.subscribe((current) =>
console.log(Option.isSome(current) ? 'signed in' : 'signed out'),
);
// An Atom of the same value, for Atom-driven code.
const atom = Identity.atom(identity);
return { unsubscribe, atom };
});
```
## Create an identity and update its profile
`create` makes the identity and this device its first member, and stores the device's key on the device. `updateProfile`
changes the display name and any profile data, such as an avatar.
```ts
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { Identity } from '@dxos/halo';
const signIn = Effect.gen(function* () {
const existing = yield* Identity.getSnapshot;
if (Option.isSome(existing)) {
return existing.value;
}
return yield* Identity.create({ displayName: 'Ada', deviceLabel: 'Laptop' });
});
const rename = Identity.updateProfile({
displayName: 'Ada Lovelace',
data: { emoji: '🦊' },
});
const me = await Effect.runPromise(signIn.pipe(Effect.provide(halo)));
console.log(me.did, me.displayName);
```
`personalSpaceId` is the id of the identity's [personal space](/docs/halo/concepts/identities-devices/), or nothing before
the identity exists.
```ts
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { Identity } from '@dxos/halo';
const personalSpace = Effect.gen(function* () {
const id = yield* Identity.personalSpaceId;
return Option.getOrUndefined(id);
});
```
## Devices
`getDevicesSnapshot` lists the identity's devices once, and `devices` streams the list. Each device has a key, a kind, a
presence and, when set, a label.
```ts
import * as Effect from 'effect/Effect';
import * as Stream from 'effect/Stream';
import { Identity } from '@dxos/halo';
const listDevices = Effect.gen(function* () {
const devices = yield* Identity.getDevicesSnapshot;
for (const device of devices) {
console.log(device.label ?? device.key, device.kind, device.presence);
}
});
// The same list, again on every change.
const watchDevices = Identity.devices.pipe(
Stream.runForEach((devices) =>
Effect.sync(() => console.log(devices.length, 'devices')),
),
);
```
To add one, `share` starts a device invitation and `join` accepts it on the new device. Both return an
[`Invitation.Flow`](/docs/halo/api/invitation/). `invitations` streams the device invitations this device has open.
```ts
import * as Effect from 'effect/Effect';
import { Identity, Invitation } from '@dxos/halo';
const addDevice = Effect.gen(function* () {
const flow = yield* Identity.share({ authMethod: 'shared-secret' });
console.log('Open this on the new device:', yield* Invitation.code(flow));
return flow;
});
declare const invitationCode: string;
const joinIdentity = Identity.join(invitationCode);
```
## Recovery credentials
`createRecoveryCredential` with no options returns a recovery code. Pass `externalKey` to register a passkey instead.
`recover` admits a new device with a recovery code, a passkey assertion or an OAuth token, and
`revokeRecoveryCredential` withdraws a credential by its lookup key. HALO refuses to revoke the last active one.
```ts
import * as Effect from 'effect/Effect';
import { Identity } from '@dxos/halo';
const backup = Effect.gen(function* () {
const { recoveryCode } = yield* Identity.createRecoveryCredential();
console.log('Write this down:', recoveryCode);
});
declare const recoveryCode: string;
const restore = Identity.recover({ recoveryCode });
declare const lookupKey: string;
const revoke = Identity.revokeRecoveryCredential(lookupKey);
```
Recovery needs a DXOS account. EDGE stores recovery credentials only for identities linked to an account, and for now
you can create an account only through [Composer](https://composer.space).
A passkey signs a challenge before `recover`. `requestRecoveryChallenge` gets it from EDGE;
[Passkey recovery](/docs/halo/guides/passkey-recovery/) walks through the whole ceremony.
```ts
import { Identity } from '@dxos/halo';
// For a passkey: the challenge the passkey signs before `recover`.
const challenge = Identity.requestRecoveryChallenge;
```
## Credentials
`credentials` streams the credentials the identity holds. A recovery credential carries its kind, label, lookup key and
whether it has been revoked.
```ts
import * as Effect from 'effect/Effect';
import * as Stream from 'effect/Stream';
import { Identity } from '@dxos/halo';
const activeRecovery = Effect.gen(function* () {
const [credentials] = yield* Identity.credentials.pipe(
Stream.take(1),
Stream.runCollect,
);
return credentials.flatMap((credential) =>
credential.recovery && !credential.recovery.revoked
? [credential.recovery]
: [],
);
});
```
## Service access and API tokens
`grantServiceAccess` writes a credential that lets the identity use an EDGE or Hub service. `createApiToken` mints a token
a CLI or a server can use to act as the identity. The token's secret is returned once, so store it straight away.
`getEdgeIdentity` is the identity as EDGE authenticates it, for an HTTP or WebSocket client.
```ts
import * as Effect from 'effect/Effect';
import { Identity } from '@dxos/halo';
const connect = Effect.gen(function* () {
yield* Identity.grantServiceAccess({
serverName: 'hub.example.com',
capabilities: ['compute'],
});
return yield* Identity.createApiToken({
label: 'CI',
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
});
});
// The identity as EDGE authenticates it, for an HTTP or WebSocket client.
const edgeIdentity = Identity.getEdgeIdentity;
```
## Members at a glance
| Member | Summary |
| --------------------------------------------------------------- | ---------------------------------------- |
| `Service` | The Effect service |
| `getSnapshot` `identity` `atom` | The identity: once, as a stream, as an Atom |
| `create` `updateProfile` | Make the identity and edit its profile |
| `personalSpaceId` | The identity's personal space |
| `getDevicesSnapshot` `devices` | The identity's devices |
| `share` `join` `invitations` | Device invitations |
| `createRecoveryCredential` `recover` `revokeRecoveryCredential` | Recovery |
| `requestRecoveryChallenge` | The challenge a passkey signs |
| `credentials` | The credentials the identity holds |
| `grantServiceAccess` `createApiToken` `getEdgeIdentity` | Access to EDGE and Hub services |
The full list is in the [API reference](/docs/halo/resources/api-reference/).
## Errors
Every function that can fail fails with `IdentityError`, whose message says what went wrong. Handle it by its tag:
```ts
import * as Effect from 'effect/Effect';
const safeRename = rename.pipe(
Effect.catchTag('IdentityError', (error) =>
Effect.sync(() => console.error('Could not rename:', error.message)),
),
);
```
`@dxos/halo` also exports the errors Keyhive raises when it checks a membership operation. They name why a delegation or a
revocation was refused:
| Error | Raised when |
| ------------------------ | ------------------------------------------------------------------------ |
| `NotAuthorizedError` | The acting member lacks the access, such as revoking without seniority |
| `UnknownPrincipalError` | The identity, device or group named is not known |
| `InvalidSignatureError` | A signed operation fails verification |
| `MissingDependencyError` | An operation refers to earlier operations that have not arrived |
## Related
- [Your profile](/docs/halo/learn/your-profile/), [Add a device](/docs/halo/learn/add-device/) and
[Recover an identity](/docs/halo/learn/recover-identity/) in the learning path.
- [Identities and devices](/docs/halo/concepts/identities-devices/) and
[Recovery and credentials](/docs/halo/concepts/recovery-credentials/)
---
# Invitation
Drive an invitation flow from either side.
```ts
import { Invitation } from '@dxos/halo';
```
An invitation lets a device into an identity, or a person into a space. You never make one with this namespace directly:
`Identity.share`, `Identity.join`, `Space.share` and `Space.join` each return an `Invitation.Flow`, and `Invitation` drives
that flow from either side. Its functions need no service, because they act on the flow handle itself.
## Choose how the invitation works
`share` takes options that decide who can redeem the invitation and how:
- `authMethod`: `'shared-secret'` makes the guest enter the authorization code the host shows; `'none'` admits anyone with
the invitation code; `'known-public-key'` admits only a guest whose key the host names.
- `type`: `'interactive'` needs host and guest online together; `'delegated'` can be redeemed while the host is offline;
`'multiuse'` admits more than one guest.
- `target`: with `'known-public-key'`, the key or DID of the one guest it admits.
```ts
import { Invitation, Space } from '@dxos/halo';
const options: Invitation.ShareOptions = {
authMethod: 'none', // anyone with the code may join
type: 'delegated', // redeemable while the host is offline
multiUse: true, // more than one guest
};
const open = Space.share(spaceId, options);
```
## Read the code
`Invitation.code` is the string to send to the guest, usually inside a link or a QR code.
```ts
import * as Effect from 'effect/Effect';
import { Invitation } from '@dxos/halo';
const showCode = Effect.gen(function* () {
const code = yield* Invitation.code(flow);
return `https://app.example.com/join?code=${code}`;
});
```
## Follow a flow
`Invitation.events` streams the flow's progress: `connecting`, `connected`, `readyForAuthentication`, `authenticating`,
then one of `success`, `cancelled` or `error`. On the host, `readyForAuthentication` carries the authorization code to
show. On the guest, it is the moment to call `Invitation.authenticate`.
```ts
import * as Effect from 'effect/Effect';
import * as Stream from 'effect/Stream';
import { Invitation } from '@dxos/halo';
const follow = Invitation.events(flow).pipe(
Stream.takeUntil(
(event) =>
event._tag === 'success' ||
event._tag === 'error' ||
event._tag === 'cancelled',
),
Stream.runForEach((event) =>
Effect.sync(() => {
switch (event._tag) {
case 'readyForAuthentication':
return console.log('auth code', event.authCode);
case 'success':
return console.log('joined space', event.result.spaceId);
case 'error':
return console.error(event.message);
}
}),
),
);
```
```ts
import * as Effect from 'effect/Effect';
import * as Stream from 'effect/Stream';
import { Invitation, Space } from '@dxos/halo';
declare const code: string;
declare const authCode: string;
const join = Effect.gen(function* () {
const flow = yield* Space.join(code);
yield* Invitation.events(flow).pipe(
Stream.filter((event) => event._tag === 'readyForAuthentication'),
Stream.take(1),
Stream.runDrain,
);
yield* Invitation.authenticate(flow, authCode);
});
```
## Cancel a flow
Either side can cancel. The host can list its open flows with `Space.invitations` or `Identity.invitations`.
```ts
import { Invitation, Space } from '@dxos/halo';
const stop = Invitation.cancel(flow);
// the flows this host has open for the space
const pending = Space.invitations(spaceId);
```
## Members at a glance
| Member | Summary |
| ----------------- | --------------------------------------------------- |
| `Flow` | An invitation in progress, host or guest side |
| `code` | The invitation code to send |
| `events` | The flow's progress as a stream |
| `authenticate` | Send the authorization code |
| `cancel` | End the flow |
| `ShareOptions` | `authMethod`, `type`, `multiUse` and `target` |
The full list is in the [API reference](/docs/halo/resources/api-reference/).
## Errors
`code` and `authenticate` fail with `InvitationError`, for example when the guest sends a wrong authorization code. The
flow also emits an `error` event, so a UI following `events` needs no separate handler.
```ts
import * as Effect from 'effect/Effect';
import { Invitation } from '@dxos/halo';
const retry = Invitation.authenticate(flow, authCode).pipe(
Effect.catchTag('InvitationError', (error) =>
Effect.sync(() => console.error('Wrong code?', error.message)),
),
);
```
`share` and `join` fail with the error of the namespace that started the flow: `IdentityError` for a device invitation and
`SpaceError` for a space invitation.
## Related
- [Invitations](/docs/halo/concepts/invitations/)
- [Invite people](/docs/halo/learn/invite-people/) and [Add a device](/docs/halo/learn/add-device/) in the learning path.
---
# Space
Create spaces and manage their members.
```ts
import { Space } from '@dxos/halo';
```
`Space` manages spaces as units of membership: which spaces exist, who belongs to each and what they may do. It does not
read or write the data inside a space; that is ECHO's [Database](/docs/echo/api/database/), reached by the same space
id. Every function needs `Space.Service`, which `layerClient` provides along with `Identity.Service`.
## List spaces
`Space.spaces` is a stream of every space the identity belongs to. It emits the current list straight away, so take one
value to read it once. `Space.get` looks one up by id.
```ts
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import * as Stream from 'effect/Stream';
import { Space } from '@dxos/halo';
import { type SpaceId } from '@dxos/keys';
const listSpaces = Effect.gen(function* () {
const [spaces] = yield* Space.spaces.pipe(Stream.take(1), Stream.runCollect);
for (const space of spaces) {
console.log(space.id, space.name, space.state);
}
});
const findSpace = (id: SpaceId) =>
Space.get(id).pipe(Effect.map(Option.getOrUndefined));
```
## Create a space
`create` returns the new space's id, name and state. Wait for `waitReady` before you open its database.
`membershipPolicy: 'locked'` creates a space no one else can join.
```ts
import * as Effect from 'effect/Effect';
import { Space } from '@dxos/halo';
const createSpace = Effect.gen(function* () {
const space = yield* Space.create({
name: 'Team',
membershipPolicy: 'invite',
});
yield* Space.waitReady(space.id);
return space;
});
```
## Members
`members` is a stream of the member list. Each member has a DID, a display name, an access level (`role`) and whether
it is online. `updateMemberRole` changes a member's access and `removeMember` revokes it; both take the member's DID and
need `admin`, or [causal seniority](/docs/halo/concepts/spaces-membership/) to revoke.
```ts
import * as Effect from 'effect/Effect';
import * as Stream from 'effect/Stream';
import { Space } from '@dxos/halo';
const listMembers = Effect.gen(function* () {
const [members] = yield* Space.members(spaceId).pipe(
Stream.take(1),
Stream.runCollect,
);
for (const member of members) {
console.log(member.displayName, member.role, member.online);
}
});
const demote = Space.updateMemberRole(spaceId, graceDid, 'read');
const remove = Space.removeMember(spaceId, graceDid);
```
## Share and join
`share` starts a space invitation and `join` accepts one. Both return an [`Invitation.Flow`](/docs/halo/api/invitation/).
```ts
import * as Effect from 'effect/Effect';
import { Invitation, Space } from '@dxos/halo';
const invite = Effect.gen(function* () {
const flow = yield* Space.share(spaceId, { authMethod: 'shared-secret' });
console.log('Send this to Grace:', yield* Invitation.code(flow));
return flow;
});
declare const code: string;
const accept = Space.join(code);
```
## Archives and EDGE replication
`exportSpace` writes the space to an archive, and `importSpace` creates a new space from one. `setEdgeReplication` turns
replication through EDGE on or off; with it off, the space syncs only between peers that are online together.
```ts
import * as Effect from 'effect/Effect';
import { Space } from '@dxos/halo';
const backUp = Effect.gen(function* () {
const space = yield* createSpace;
yield* Space.setEdgeReplication(space.id, 'enabled');
const archive = yield* Space.exportSpace(space.id);
const copy = yield* Space.importSpace(archive);
return copy.id;
});
```
## Members at a glance
| Member | Summary |
| --------------------------------------- | ----------------------------------------------- |
| `Service` | The Effect service |
| `spaces` `get` | The identity's spaces |
| `create` `waitReady` | Make a space and wait until it opens |
| `members` | The member list as a stream |
| `updateMemberRole` `removeMember` | Change a member's access |
| `share` `join` `invitations` | Space invitations |
| `exportSpace` `importSpace` | Archives |
| `setEdgeReplication` | Sync through EDGE, or only peer to peer |
The full list is in the [API reference](/docs/halo/resources/api-reference/).
## Errors
Every function that can fail fails with `SpaceError`, whose message says what went wrong.
```ts
import * as Effect from 'effect/Effect';
const safeRemove = remove.pipe(
Effect.catchTag('SpaceError', (error) =>
Effect.sync(() => console.error('Could not remove:', error.message)),
),
);
```
A membership change Keyhive refuses names the reason with one of the errors `@dxos/halo` exports:
| Error | Raised when |
| ------------------------ | ------------------------------------------------------------------------ |
| `NotAuthorizedError` | The acting member lacks the access, such as revoking without seniority |
| `UnknownPrincipalError` | The member named is not known to the space |
| `InvalidSignatureError` | A signed operation fails verification |
| `MissingDependencyError` | An operation refers to earlier operations that have not arrived |
## Related
- [Spaces and membership](/docs/halo/concepts/spaces-membership/)
- [Spaces and databases](/docs/echo/concepts/spaces/) for the data in a space.
- [Your spaces](/docs/halo/learn/your-spaces/), [Invite people](/docs/halo/learn/invite-people/) and
[Your contacts](/docs/halo/learn/your-contacts/) in the learning path.
---
# API reference
Generated API reference for the HALO packages.
- `@dxos/halo`: the `Identity`, `Invitation` and `Space` namespaces.
- `@dxos/halo-react`: React hooks for those namespaces.
---
# Glossary
ECHO terms, such as space, object and epoch, are in ECHO's [glossary](/docs/additional-resources/glossary/).
### Access level
A member's level in a space: `pull`, `read`, `edit` or `admin`. Each level includes the ones before it. The API calls it
the member's `role`. See [Spaces and membership](/docs/halo/concepts/spaces-membership/).
### Auth method
How the guest of an [invitation](#invitation) proves it is the intended guest: `none`, `shared-secret` or
`known-public-key`.
### Authorization code
A short code the host of an [invitation](#invitation) shows and the guest types in. It travels by a different route from
the [invitation code](#invitation-code), so intercepting one is not enough to get in.
### Contact card
What each side of an [invitation](#invitation) gives the other: it names an identity or device and carries the keys others
need to encrypt to it and check its signatures.
### Credential
A signed statement an [identity](#identity) holds, such as the admission of a [device](#device), a
[recovery credential](#recovery-credential) or access to a service.
### Delegation
A signed statement that admits an identity, a device or a key to a group with an [access level](#access-level). A member
can delegate no more access than it holds. See [Spaces and membership](/docs/halo/concepts/spaces-membership/).
### Device
A key pair that an [identity](#identity) has authorized to act for it. In a browser, each profile on each site is a
device, and its tabs share it. A computer can hold several devices. See
[Identities and devices](/docs/halo/concepts/identities-devices/).
### DID
The decentralized identifier that names an [identity](#identity), such as `did:halo:BA25QRC2FEWCSAMRP4RZL65LWJ7352CKE`.
### Group key
The key a space's current members share, which encrypts the space's changes. It changes when membership does, and `pull`
members relay data encrypted with it without reading it.
### Identity
One user, as a Keyhive group named by a [DID](#did), whose members are the user's [devices](#device). The client creates
it; no server issues it.
### Invitation
A short-lived offer that lets a [device](#device) into an identity or a member into a space. See
[Invitations](/docs/halo/concepts/invitations/).
### Invitation code
The code the guest of an [invitation](#invitation) redeems with `join`, usually sent as a link.
### Keyhive
The access control and group encryption system HALO is built on. It represents [identities](#identity), devices and
spaces as groups, grants [access](#access-level) through signed delegations, and keeps each space's encryption key for its
current members. See [Spaces and membership](/docs/halo/concepts/spaces-membership/).
### Member
An [identity](#identity) admitted to a space, with an [access](#access-level) level.
### Membership policy
Whether a space admits new members by invitation (`invite`) or not at all (`locked`).
### Personal space
A space that every identity has and only its own devices join. It carries the user's data from device to device.
### Prekey
A one-time key published in advance, so a guest can encrypt to the host of a delegated [invitation](#invitation) while the
host is offline.
### Recovery code
A phrase of words that holds a [recovery credential](#recovery-credential)'s key. Sometimes called a paper key.
### Recovery credential
A key outside every device that can admit a new device to the identity: a [recovery code](#recovery-code), a passkey or
an OAuth account. See [Recovery and credentials](/docs/halo/concepts/recovery-credentials/).
### Revocation
A signed statement that removes a member from a group. An admin can revoke anyone; another member only those it has
causal seniority over.
### Space key
The value that identifies a space. Newer APIs use the space id.
---
# llms.txt
The HALO site map and full text for agents, in the llms.txt format.
Point an agent at these files before asking it to work with HALO. They follow the [llms.txt](https://llmstxt.org/) format and cover every written page of this section.
| File | Contents |
| -------------------------------------------- | ---------------------------------------------------------- |
| [`/halo/llms.txt`](/halo/llms.txt) | Each HALO page with its summary and a link to its Markdown |
| [`/halo/llms-full.txt`](/halo/llms-full.txt) | Every HALO page in one file |
Any page is also served as Markdown at its URL with `.md` in place of the trailing slash.