Skip to content

Why HALO?

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.

Create an identity, create a space, invite someone. No step talks to a sign-up server.

import * as
import Effect
Effect
from 'effect/Effect';
import {
import Identity
Identity
,
import Invitation
Invitation
,
import Space
Space
} from '@dxos/halo';
const
const start: Effect.Effect<{
me: {
readonly did: IdentityDid;
readonly displayName?: string | undefined;
readonly identityKey?: string | undefined;
readonly data?: {
readonly [x: string]: any;
} | undefined;
};
invite: string;
}, IdentityError | SpaceError | InvitationError, Identity.Service | Space.Service>
start
=
import Effect
Effect
.
const gen: <Effect.Effect<{
readonly did: IdentityDid;
readonly displayName?: string | undefined;
readonly identityKey?: string | undefined;
readonly data?: {
readonly [x: string]: any;
} | undefined;
}, IdentityError, Identity.Service> | Effect.Effect<{
readonly id: SpaceId;
readonly state: "inactive" | "closed" | "ready";
readonly name?: string | undefined;
}, SpaceError, Space.Service> | Effect.Effect<Invitation.Flow, SpaceError, Space.Service> | Effect.Effect<...>, {
...;
}>(f: () => Generator<...>) => Effect.Effect<...> (+1 overload)

Provides a way to write effectful code using generator functions, simplifying control flow and error handling.

When to use

Use when you want to write effectful code that looks and behaves like synchronous code, while still handling asynchronous tasks, errors, and complex control flow such as loops and conditions.

Generator functions work similarly to async/await but keep errors, requirements, and interruption in the Effect type. You can yield* values from effects and return the final result at the end.

Example (Sequencing effects with generators)

import { Data, Effect } from "effect"
class DiscountRateError extends Data.TaggedError("DiscountRateError")<{}> {}
const addServiceCharge = (amount: number) => amount + 1
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, DiscountRateError> =>
discountRate === 0
? Effect.fail(new DiscountRateError())
: Effect.succeed(total - (total * discountRate) / 100)
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))
export const program = Effect.gen(function*() {
const transactionAmount = yield* fetchTransactionAmount
const discountRate = yield* fetchDiscountRate
const discountedAmount = yield* applyDiscount(
transactionAmount,
discountRate
)
const finalAmount = addServiceCharge(discountedAmount)
return `Final amount to charge: ${finalAmount}`
})
await Effect.runPromise(program) // => "Final amount to charge: 96"

@stability ― stable

@category ― constructors

@since ― 2.0.0

gen
(function* () {
// a key pair, made on this device
const
const me: {
readonly did: IdentityDid;
readonly displayName?: string | undefined;
readonly identityKey?: string | undefined;
readonly data?: {
readonly [x: string]: any;
} | undefined;
}
me
= yield*
import Identity
Identity
.
const create: (options?: {
displayName?: string;
data?: Record<string, unknown>;
deviceLabel?: string;
}) => Effect.Effect<Identity.Info, IdentityError, Identity.Service>

Create the local identity (requires

Service

).

create
({
displayName?: string | undefined
displayName
: 'Ada' });
const
const team: {
readonly id: SpaceId;
readonly state: "inactive" | "closed" | "ready";
readonly name?: string | undefined;
}
team
= yield*
import Space
Space
.
const create: (options?: Space.CreateOptions) => Effect.Effect<Space.Info, SpaceError, Space.Service>

Create a new space (requires

Service

).

create
({
name?: string | undefined
name
: 'Team' });
const
const flow: Invitation.Flow
flow
= yield*
import Space
Space
.
const share: (id: SpaceId, options?: Invitation.ShareOptions) => Effect.Effect<Invitation.Flow, SpaceError, Space.Service>

Initiate a space invitation (requires

Service

).

share
(
const team: {
readonly id: SpaceId;
readonly state: "inactive" | "closed" | "ready";
readonly name?: string | undefined;
}
team
.
id: SpaceId
id
, {
authMethod?: Invitation.AuthMethod | undefined
authMethod
: 'shared-secret' });
// send the code; no sign-up server involved
return {
me: {
readonly did: IdentityDid;
readonly displayName?: string | undefined;
readonly identityKey?: string | undefined;
readonly data?: {
readonly [x: string]: any;
} | undefined;
}
me
,
invite: string
invite
: yield*
import Invitation
Invitation
.
const code: (flow: Invitation.Flow) => Effect.Effect<string, InvitationError>

Encoded, shareable code for a flow.

code
(
const flow: Invitation.Flow
flow
) };
});
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

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 walks through it.

When two peers sync a space, each one decides whether to accept the other’s changes. Membership is a 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 explains the graph.

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 shows how.

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.