@dxos/halo 0.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/API_AUDIT.md +396 -0
- package/CONSUMER_MIGRATION.md +343 -0
- package/LICENSE +105 -0
- package/MIGRATION.md +254 -0
- package/MIGRATION_PLAN.md +217 -0
- package/README.md +25 -0
- package/SPEC.mdl +137 -0
- package/dist/lib/neutral/index.mjs +186 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/types/src/Identity.d.ts +166 -0
- package/dist/types/src/Identity.d.ts.map +1 -0
- package/dist/types/src/Invitation.d.ts +87 -0
- package/dist/types/src/Invitation.d.ts.map +1 -0
- package/dist/types/src/Space.d.ts +158 -0
- package/dist/types/src/Space.d.ts.map +1 -0
- package/dist/types/src/errors.d.ts +228 -0
- package/dist/types/src/errors.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/docs/halo.drawio.svg +119 -0
- package/moon.yml +11 -0
- package/package.json +31 -0
- package/src/Identity.ts +195 -0
- package/src/Invitation.ts +95 -0
- package/src/Space.ts +192 -0
- package/src/errors.ts +41 -0
- package/src/index.ts +9 -0
- package/tsconfig.json +19 -0
package/src/Identity.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import * as Context from 'effect/Context';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Option from 'effect/Option';
|
|
10
|
+
import * as Schema from 'effect/Schema';
|
|
11
|
+
import * as Stream from 'effect/Stream';
|
|
12
|
+
|
|
13
|
+
import { IdentityDid } from '@dxos/keys';
|
|
14
|
+
|
|
15
|
+
import { type IdentityError } from './errors';
|
|
16
|
+
import * as Invitation from './Invitation';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Device kind (platform / host class). Replaces the legacy protobuf `DeviceType` enum;
|
|
20
|
+
* `agent`/`agent-managed` denote EDGE- or Hub-hosted agent devices.
|
|
21
|
+
*/
|
|
22
|
+
export const DeviceKind = Schema.Literal('unknown', 'browser', 'native', 'mobile', 'agent', 'agent-managed');
|
|
23
|
+
export type DeviceKind = typeof DeviceKind.Type;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Public view of the local identity. Keyed by the DID; `identityKey` (hex) is retained for
|
|
27
|
+
* consumers that seed deterministic UI (avatar/hue) or address the legacy credential key.
|
|
28
|
+
* `data` carries arbitrary profile metadata (e.g. avatar emoji, hue).
|
|
29
|
+
*/
|
|
30
|
+
export const Info = Schema.Struct({
|
|
31
|
+
did: IdentityDid,
|
|
32
|
+
/** Hex-encoded identity (credential) key, when known. */
|
|
33
|
+
identityKey: Schema.optional(Schema.String),
|
|
34
|
+
displayName: Schema.optional(Schema.String),
|
|
35
|
+
/** Arbitrary profile metadata. */
|
|
36
|
+
data: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
|
|
37
|
+
});
|
|
38
|
+
export type Info = typeof Info.Type;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Public view of a device belonging to the local identity. Replaces the legacy `Device` proxy
|
|
42
|
+
* type (`deviceKey` → `key`).
|
|
43
|
+
*/
|
|
44
|
+
export const DeviceInfo = Schema.Struct({
|
|
45
|
+
/** Hex-encoded device key. */
|
|
46
|
+
key: Schema.String,
|
|
47
|
+
kind: Schema.optional(DeviceKind),
|
|
48
|
+
label: Schema.optional(Schema.String),
|
|
49
|
+
/** Whether this device is the local (current) device. */
|
|
50
|
+
current: Schema.Boolean,
|
|
51
|
+
});
|
|
52
|
+
export type DeviceInfo = typeof DeviceInfo.Type;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Public view of a HALO credential. Replaces direct consumption of the protobuf `Credential`:
|
|
56
|
+
* `type` is the subject assertion's `@type`, `id` its hex-encoded credential id.
|
|
57
|
+
*/
|
|
58
|
+
export const Credential = Schema.Struct({
|
|
59
|
+
/** Hex-encoded credential id, when assigned. */
|
|
60
|
+
id: Schema.optional(Schema.String),
|
|
61
|
+
/** The subject assertion's `@type` (e.g. `dxos.halo.credentials.IdentityRecovery`). */
|
|
62
|
+
type: Schema.String,
|
|
63
|
+
issuanceDate: Schema.optional(Schema.DateFromSelf),
|
|
64
|
+
});
|
|
65
|
+
export type Credential = typeof Credential.Type;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Options for a self-issued `ServiceAccess` credential granting the identity access to an
|
|
69
|
+
* EDGE/Hub service.
|
|
70
|
+
*/
|
|
71
|
+
export type ServiceAccessOptions = {
|
|
72
|
+
/** Target server name (e.g. `hub.dxos.network`). */
|
|
73
|
+
readonly serverName: string;
|
|
74
|
+
/** Capabilities to grant (e.g. `['composer:beta']`). */
|
|
75
|
+
readonly capabilities: readonly string[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Recovery credential presented to re-admit a device to an existing identity.
|
|
80
|
+
*/
|
|
81
|
+
export type RecoverArgs =
|
|
82
|
+
| { readonly recoveryCode: string }
|
|
83
|
+
| { readonly token: string }
|
|
84
|
+
| { readonly recoveryProof: string };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Identity and device management, plus device invitations. `share`/`join` construct
|
|
88
|
+
* {@link Invitation.Flow}s driven through the {@link Invitation} flow verbs; `invitations`
|
|
89
|
+
* observes the active (host-created) device-invitation flows.
|
|
90
|
+
*/
|
|
91
|
+
export class Service extends Context.Tag('@dxos/halo/Identity')<
|
|
92
|
+
Service,
|
|
93
|
+
{
|
|
94
|
+
/**
|
|
95
|
+
* The local identity (`Option.none` when none exists) as a stream that emits the current
|
|
96
|
+
* value immediately on subscription. Take the first element for a one-shot read; subscribe
|
|
97
|
+
* for updates.
|
|
98
|
+
*/
|
|
99
|
+
readonly identity: Stream.Stream<Option.Option<Info>>;
|
|
100
|
+
/**
|
|
101
|
+
* Synchronous snapshot of the local identity (`Option.none` when none exists). For imperative
|
|
102
|
+
* callers (non-React, non-Effect) that need the current value without subscribing.
|
|
103
|
+
*/
|
|
104
|
+
readonly getSnapshot: () => Option.Option<Info>;
|
|
105
|
+
/**
|
|
106
|
+
* Subscribe to local-identity changes (imperative). Invokes `callback` immediately with the
|
|
107
|
+
* current value, then on each change. Returns an unsubscribe function.
|
|
108
|
+
*/
|
|
109
|
+
readonly subscribe: (callback: (identity: Option.Option<Info>) => void) => () => void;
|
|
110
|
+
/** Create the local identity (and its first device). */
|
|
111
|
+
readonly create: (options?: { displayName?: string; deviceLabel?: string }) => Effect.Effect<Info, IdentityError>;
|
|
112
|
+
/** Re-admit this device to an existing identity via a recovery credential. */
|
|
113
|
+
readonly recover: (args: RecoverArgs) => Effect.Effect<Info, IdentityError>;
|
|
114
|
+
/** Update the identity profile. */
|
|
115
|
+
readonly updateProfile: (profile: {
|
|
116
|
+
displayName?: string;
|
|
117
|
+
data?: Record<string, unknown>;
|
|
118
|
+
}) => Effect.Effect<Info, IdentityError>;
|
|
119
|
+
/** Devices belonging to the local identity; emits the current set immediately. */
|
|
120
|
+
readonly devices: Stream.Stream<readonly DeviceInfo[]>;
|
|
121
|
+
/** Synchronous snapshot of the local identity's devices, for imperative callers. */
|
|
122
|
+
readonly getDevicesSnapshot: () => readonly DeviceInfo[];
|
|
123
|
+
/** HALO credentials of the local identity; emits the current set immediately. */
|
|
124
|
+
readonly credentials: Stream.Stream<readonly Credential[]>;
|
|
125
|
+
/**
|
|
126
|
+
* Grant this identity access to an EDGE/Hub service by writing a `ServiceAccess` credential
|
|
127
|
+
* (self-issued). Replaces hand-constructing the protobuf credential at the call site.
|
|
128
|
+
*/
|
|
129
|
+
readonly grantServiceAccess: (options: ServiceAccessOptions) => Effect.Effect<void, IdentityError>;
|
|
130
|
+
/** Initiate a device invitation (host side). */
|
|
131
|
+
readonly share: (options?: Invitation.ShareOptions) => Effect.Effect<Invitation.Flow, IdentityError>;
|
|
132
|
+
/** Redeem a device-invitation code on a new device (guest side). */
|
|
133
|
+
readonly join: (code: string) => Effect.Effect<Invitation.Flow, IdentityError>;
|
|
134
|
+
/** Active (host-created) device-invitation flows; emits the current set immediately. */
|
|
135
|
+
readonly invitations: Stream.Stream<readonly Invitation.Flow[]>;
|
|
136
|
+
}
|
|
137
|
+
>() {}
|
|
138
|
+
|
|
139
|
+
/** The local identity as a current-value stream (requires {@link Service}). */
|
|
140
|
+
export const identity: Stream.Stream<Option.Option<Info>, never, Service> = Stream.unwrap(
|
|
141
|
+
Effect.map(Service, (service) => service.identity),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
/** Synchronous snapshot of the local identity (requires {@link Service}). */
|
|
145
|
+
export const getSnapshot: Effect.Effect<Option.Option<Info>, never, Service> = Effect.map(Service, (service) =>
|
|
146
|
+
service.getSnapshot(),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
/** Create the local identity (requires {@link Service}). */
|
|
150
|
+
export const create = (options?: {
|
|
151
|
+
displayName?: string;
|
|
152
|
+
deviceLabel?: string;
|
|
153
|
+
}): Effect.Effect<Info, IdentityError, Service> => Effect.flatMap(Service, (service) => service.create(options));
|
|
154
|
+
|
|
155
|
+
/** Re-admit this device via a recovery credential (requires {@link Service}). */
|
|
156
|
+
export const recover = (args: RecoverArgs): Effect.Effect<Info, IdentityError, Service> =>
|
|
157
|
+
Effect.flatMap(Service, (service) => service.recover(args));
|
|
158
|
+
|
|
159
|
+
/** Update the identity profile (requires {@link Service}). */
|
|
160
|
+
export const updateProfile = (profile: {
|
|
161
|
+
displayName?: string;
|
|
162
|
+
data?: Record<string, unknown>;
|
|
163
|
+
}): Effect.Effect<Info, IdentityError, Service> => Effect.flatMap(Service, (service) => service.updateProfile(profile));
|
|
164
|
+
|
|
165
|
+
/** Devices belonging to the local identity as a current-value stream (requires {@link Service}). */
|
|
166
|
+
export const devices: Stream.Stream<readonly DeviceInfo[], never, Service> = Stream.unwrap(
|
|
167
|
+
Effect.map(Service, (service) => service.devices),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/** Synchronous snapshot of the local identity's devices (requires {@link Service}). */
|
|
171
|
+
export const getDevicesSnapshot: Effect.Effect<readonly DeviceInfo[], never, Service> = Effect.map(Service, (service) =>
|
|
172
|
+
service.getDevicesSnapshot(),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/** HALO credentials as a current-value stream (requires {@link Service}). */
|
|
176
|
+
export const credentials: Stream.Stream<readonly Credential[], never, Service> = Stream.unwrap(
|
|
177
|
+
Effect.map(Service, (service) => service.credentials),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
/** Grant the identity access to an EDGE/Hub service (requires {@link Service}). */
|
|
181
|
+
export const grantServiceAccess = (options: ServiceAccessOptions): Effect.Effect<void, IdentityError, Service> =>
|
|
182
|
+
Effect.flatMap(Service, (service) => service.grantServiceAccess(options));
|
|
183
|
+
|
|
184
|
+
/** Initiate a device invitation (requires {@link Service}). */
|
|
185
|
+
export const share = (options?: Invitation.ShareOptions): Effect.Effect<Invitation.Flow, IdentityError, Service> =>
|
|
186
|
+
Effect.flatMap(Service, (service) => service.share(options));
|
|
187
|
+
|
|
188
|
+
/** Redeem a device-invitation code (requires {@link Service}). */
|
|
189
|
+
export const join = (code: string): Effect.Effect<Invitation.Flow, IdentityError, Service> =>
|
|
190
|
+
Effect.flatMap(Service, (service) => service.join(code));
|
|
191
|
+
|
|
192
|
+
/** Active device-invitation flows as a current-value stream (requires {@link Service}). */
|
|
193
|
+
export const invitations: Stream.Stream<readonly Invitation.Flow[], never, Service> = Stream.unwrap(
|
|
194
|
+
Effect.map(Service, (service) => service.invitations),
|
|
195
|
+
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Stream from 'effect/Stream';
|
|
9
|
+
|
|
10
|
+
import { type IdentityDid, type SpaceId } from '@dxos/keys';
|
|
11
|
+
|
|
12
|
+
import { type InvitationError } from './errors';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether an invitation admits a device to an identity or a member to a space.
|
|
16
|
+
*/
|
|
17
|
+
export type Kind = 'device' | 'space';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How a guest proves it may redeem the invitation.
|
|
21
|
+
* Mirrors the legacy `Invitation.AuthMethod` protobuf enum.
|
|
22
|
+
*/
|
|
23
|
+
export type AuthMethod = 'none' | 'shared-secret' | 'known-public-key';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Interactive (online host+guest handshake) vs delegated (offline-redeemable) invitation.
|
|
27
|
+
* Mirrors the legacy `Invitation.Type` protobuf enum.
|
|
28
|
+
*/
|
|
29
|
+
export type Type = 'interactive' | 'delegated' | 'multiuse';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Options for initiating an invitation. Passed to {@link Identity.share} / {@link Space.share}.
|
|
33
|
+
*/
|
|
34
|
+
export type ShareOptions = {
|
|
35
|
+
readonly authMethod?: AuthMethod;
|
|
36
|
+
readonly type?: Type;
|
|
37
|
+
readonly multiUse?: boolean;
|
|
38
|
+
/** Restrict a `known-public-key` invitation to a specific guest (key/DID). */
|
|
39
|
+
readonly target?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Outcome of a completed invitation flow.
|
|
44
|
+
*/
|
|
45
|
+
export type Result = {
|
|
46
|
+
readonly spaceId?: SpaceId;
|
|
47
|
+
readonly identityDid?: IdentityDid;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A lifecycle event emitted by an invitation {@link Flow}. Terminal events are `success`,
|
|
52
|
+
* `cancelled`, and `error`. Replaces polling on the legacy `Invitation.State` enum.
|
|
53
|
+
*/
|
|
54
|
+
export type Event =
|
|
55
|
+
| { readonly _tag: 'connecting' }
|
|
56
|
+
| { readonly _tag: 'connected' }
|
|
57
|
+
| { readonly _tag: 'readyForAuthentication'; readonly authCode?: string }
|
|
58
|
+
| { readonly _tag: 'authenticating' }
|
|
59
|
+
| { readonly _tag: 'success'; readonly result: Result }
|
|
60
|
+
| { readonly _tag: 'cancelled' }
|
|
61
|
+
| { readonly _tag: 'error'; readonly message: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An in-flight invitation, host or guest side. Produced by {@link Identity.share}/`join` and
|
|
65
|
+
* {@link Space.share}/`join`; its lifecycle is then driven through this handle and the
|
|
66
|
+
* {@link Service}.
|
|
67
|
+
*/
|
|
68
|
+
export interface Flow {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly kind: Kind;
|
|
71
|
+
/** Hot stream of lifecycle events; completes after a terminal event. */
|
|
72
|
+
readonly events: Stream.Stream<Event>;
|
|
73
|
+
/** Supply the auth code when the flow reaches `readyForAuthentication`. */
|
|
74
|
+
authenticate(code: string): Effect.Effect<void, InvitationError>;
|
|
75
|
+
/** Abort the flow. */
|
|
76
|
+
cancel(): Effect.Effect<void>;
|
|
77
|
+
/** Encoded, shareable invitation code (host side). */
|
|
78
|
+
readonly code: Effect.Effect<string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Supply the auth code to a flow awaiting authentication. Flow verbs operate on a {@link Flow}
|
|
83
|
+
* handle directly and require no service; the active-flow set is queried through
|
|
84
|
+
* {@link Identity} (device invitations) and {@link Space} (space invitations).
|
|
85
|
+
*/
|
|
86
|
+
export const authenticate = (flow: Flow, code: string): Effect.Effect<void, InvitationError> => flow.authenticate(code);
|
|
87
|
+
|
|
88
|
+
/** Abort a flow. */
|
|
89
|
+
export const cancel = (flow: Flow): Effect.Effect<void> => flow.cancel();
|
|
90
|
+
|
|
91
|
+
/** Lifecycle event stream of a flow. */
|
|
92
|
+
export const events = (flow: Flow): Stream.Stream<Event> => flow.events;
|
|
93
|
+
|
|
94
|
+
/** Encoded, shareable code for a flow. */
|
|
95
|
+
export const code = (flow: Flow): Effect.Effect<string> => flow.code;
|
package/src/Space.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import * as Context from 'effect/Context';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Option from 'effect/Option';
|
|
10
|
+
import * as Schema from 'effect/Schema';
|
|
11
|
+
import * as Stream from 'effect/Stream';
|
|
12
|
+
|
|
13
|
+
import { IdentityDid, SpaceId } from '@dxos/keys';
|
|
14
|
+
|
|
15
|
+
import { type SpaceError } from './errors';
|
|
16
|
+
import * as Invitation from './Invitation';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Capability level granted to a space member, matching the Keyhive `Access` enum. Ordered; each
|
|
20
|
+
* implies the previous: `pull` < `read` < `edit` < `admin`. Replaces the legacy
|
|
21
|
+
* `SpaceMember.Role` enum.
|
|
22
|
+
*/
|
|
23
|
+
export const Access = Schema.Literal('pull', 'read', 'edit', 'admin');
|
|
24
|
+
export type Access = typeof Access.Type;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lifecycle state of a space. Replaces the legacy `SpaceState` protobuf enum (subset relevant to
|
|
28
|
+
* consumers).
|
|
29
|
+
*/
|
|
30
|
+
export const State = Schema.Literal('inactive', 'closed', 'ready');
|
|
31
|
+
export type State = typeof State.Type;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Who may join a space. Replaces the legacy `MembershipPolicy` protobuf enum: `invite` admits
|
|
35
|
+
* members via invitation only; `locked` admits no new members.
|
|
36
|
+
*/
|
|
37
|
+
export const MembershipPolicy = Schema.Literal('invite', 'locked');
|
|
38
|
+
export type MembershipPolicy = typeof MembershipPolicy.Type;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a space replicates through EDGE. Replaces the legacy `EdgeReplicationSetting` protobuf
|
|
42
|
+
* enum.
|
|
43
|
+
*/
|
|
44
|
+
export const EdgeReplication = Schema.Literal('disabled', 'enabled');
|
|
45
|
+
export type EdgeReplication = typeof EdgeReplication.Type;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A space membership entry. Membership (HALO) only — liveness/presence is a separate concern
|
|
49
|
+
* (see API_AUDIT.md §3.6).
|
|
50
|
+
*/
|
|
51
|
+
export const Member = Schema.Struct({
|
|
52
|
+
/** DID of the member identity, when known. */
|
|
53
|
+
did: Schema.optional(IdentityDid),
|
|
54
|
+
/** Hex-encoded identity key of the member, for avatar/presence keying. */
|
|
55
|
+
identityKey: Schema.optional(Schema.String),
|
|
56
|
+
displayName: Schema.optional(Schema.String),
|
|
57
|
+
/** Arbitrary profile metadata (e.g. avatar emoji, hue). */
|
|
58
|
+
data: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
|
|
59
|
+
role: Access,
|
|
60
|
+
/** Whether the member is currently online. */
|
|
61
|
+
online: Schema.Boolean,
|
|
62
|
+
});
|
|
63
|
+
export type Member = typeof Member.Type;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Public view of a space. A plain snapshot — database access stays on the ECHO `Database`
|
|
67
|
+
* service keyed by {@link SpaceId}, not on this value.
|
|
68
|
+
*/
|
|
69
|
+
export const Info = Schema.Struct({
|
|
70
|
+
id: SpaceId,
|
|
71
|
+
name: Schema.optional(Schema.String),
|
|
72
|
+
state: State,
|
|
73
|
+
});
|
|
74
|
+
export type Info = typeof Info.Type;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A serialized space archive (export/import payload).
|
|
78
|
+
*/
|
|
79
|
+
export type Archive = {
|
|
80
|
+
readonly filename: string;
|
|
81
|
+
readonly contents: Uint8Array;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type CreateOptions = {
|
|
85
|
+
readonly name?: string;
|
|
86
|
+
/** Tags applied to the new space. */
|
|
87
|
+
readonly tags?: readonly string[];
|
|
88
|
+
/** Who may join the space (defaults to `invite`). */
|
|
89
|
+
readonly membershipPolicy?: MembershipPolicy;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Space management and membership, plus space invitations. `share`/`join` construct
|
|
94
|
+
* {@link Invitation.Flow}s driven through the {@link Invitation} flow verbs; `invitations`
|
|
95
|
+
* observes the active (host-created) space-invitation flows.
|
|
96
|
+
*/
|
|
97
|
+
export class Service extends Context.Tag('@dxos/halo/Space')<
|
|
98
|
+
Service,
|
|
99
|
+
{
|
|
100
|
+
/**
|
|
101
|
+
* All spaces known to the local identity as a stream that emits the current set immediately
|
|
102
|
+
* on subscription. Take the first element for a one-shot read; subscribe for updates.
|
|
103
|
+
*/
|
|
104
|
+
readonly spaces: Stream.Stream<readonly Info[]>;
|
|
105
|
+
/** Resolve a space by id. */
|
|
106
|
+
readonly get: (id: SpaceId) => Effect.Effect<Option.Option<Info>>;
|
|
107
|
+
/** Create a new space. */
|
|
108
|
+
readonly create: (options?: CreateOptions) => Effect.Effect<Info, SpaceError>;
|
|
109
|
+
/** Resolve once the space has reached the `ready` state. */
|
|
110
|
+
readonly waitReady: (id: SpaceId) => Effect.Effect<void, SpaceError>;
|
|
111
|
+
/** Enable or disable EDGE replication for a space. */
|
|
112
|
+
readonly setEdgeReplication: (id: SpaceId, setting: EdgeReplication) => Effect.Effect<void, SpaceError>;
|
|
113
|
+
/** A space's membership; emits the current set immediately, then on join/leave/role/presence. */
|
|
114
|
+
readonly members: (id: SpaceId) => Stream.Stream<readonly Member[]>;
|
|
115
|
+
/** Change a member's access level (Keyhive delegation). */
|
|
116
|
+
readonly updateMemberRole: (id: SpaceId, subject: IdentityDid, role: Access) => Effect.Effect<void, SpaceError>;
|
|
117
|
+
/** Remove a member (Keyhive revocation). */
|
|
118
|
+
readonly removeMember: (id: SpaceId, subject: IdentityDid) => Effect.Effect<void, SpaceError>;
|
|
119
|
+
/** Initiate a space invitation (host side). */
|
|
120
|
+
readonly share: (id: SpaceId, options?: Invitation.ShareOptions) => Effect.Effect<Invitation.Flow, SpaceError>;
|
|
121
|
+
/** Redeem a space-invitation code (guest side). */
|
|
122
|
+
readonly join: (code: string) => Effect.Effect<Invitation.Flow, SpaceError>;
|
|
123
|
+
/** A space's active (host-created) invitation flows; emits the current set immediately. */
|
|
124
|
+
readonly invitations: (id: SpaceId) => Stream.Stream<readonly Invitation.Flow[]>;
|
|
125
|
+
/** Export a space to an archive. */
|
|
126
|
+
readonly export: (id: SpaceId) => Effect.Effect<Archive, SpaceError>;
|
|
127
|
+
/** Import a space from an archive. */
|
|
128
|
+
readonly import: (archive: Archive, options?: { tags?: readonly string[] }) => Effect.Effect<Info, SpaceError>;
|
|
129
|
+
}
|
|
130
|
+
>() {}
|
|
131
|
+
|
|
132
|
+
/** All spaces as a current-value stream (requires {@link Service}). */
|
|
133
|
+
export const spaces: Stream.Stream<readonly Info[], never, Service> = Stream.unwrap(
|
|
134
|
+
Effect.map(Service, (service) => service.spaces),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
/** Resolve a space by id (requires {@link Service}). */
|
|
138
|
+
export const get = (id: SpaceId): Effect.Effect<Option.Option<Info>, never, Service> =>
|
|
139
|
+
Effect.flatMap(Service, (service) => service.get(id));
|
|
140
|
+
|
|
141
|
+
/** Create a new space (requires {@link Service}). */
|
|
142
|
+
export const create = (options?: CreateOptions): Effect.Effect<Info, SpaceError, Service> =>
|
|
143
|
+
Effect.flatMap(Service, (service) => service.create(options));
|
|
144
|
+
|
|
145
|
+
/** Resolve once the space is ready (requires {@link Service}). */
|
|
146
|
+
export const waitReady = (id: SpaceId): Effect.Effect<void, SpaceError, Service> =>
|
|
147
|
+
Effect.flatMap(Service, (service) => service.waitReady(id));
|
|
148
|
+
|
|
149
|
+
/** Enable or disable EDGE replication for a space (requires {@link Service}). */
|
|
150
|
+
export const setEdgeReplication = (id: SpaceId, setting: EdgeReplication): Effect.Effect<void, SpaceError, Service> =>
|
|
151
|
+
Effect.flatMap(Service, (service) => service.setEdgeReplication(id, setting));
|
|
152
|
+
|
|
153
|
+
/** A space's membership as a current-value stream (requires {@link Service}). */
|
|
154
|
+
export const members = (id: SpaceId): Stream.Stream<readonly Member[], never, Service> =>
|
|
155
|
+
Stream.unwrap(Effect.map(Service, (service) => service.members(id)));
|
|
156
|
+
|
|
157
|
+
/** Change a member's access level (requires {@link Service}). */
|
|
158
|
+
export const updateMemberRole = (
|
|
159
|
+
id: SpaceId,
|
|
160
|
+
subject: IdentityDid,
|
|
161
|
+
role: Access,
|
|
162
|
+
): Effect.Effect<void, SpaceError, Service> =>
|
|
163
|
+
Effect.flatMap(Service, (service) => service.updateMemberRole(id, subject, role));
|
|
164
|
+
|
|
165
|
+
/** Remove a member (requires {@link Service}). */
|
|
166
|
+
export const removeMember = (id: SpaceId, subject: IdentityDid): Effect.Effect<void, SpaceError, Service> =>
|
|
167
|
+
Effect.flatMap(Service, (service) => service.removeMember(id, subject));
|
|
168
|
+
|
|
169
|
+
/** Initiate a space invitation (requires {@link Service}). */
|
|
170
|
+
export const share = (
|
|
171
|
+
id: SpaceId,
|
|
172
|
+
options?: Invitation.ShareOptions,
|
|
173
|
+
): Effect.Effect<Invitation.Flow, SpaceError, Service> =>
|
|
174
|
+
Effect.flatMap(Service, (service) => service.share(id, options));
|
|
175
|
+
|
|
176
|
+
/** Redeem a space-invitation code (requires {@link Service}). */
|
|
177
|
+
export const join = (code: string): Effect.Effect<Invitation.Flow, SpaceError, Service> =>
|
|
178
|
+
Effect.flatMap(Service, (service) => service.join(code));
|
|
179
|
+
|
|
180
|
+
/** A space's active invitation flows as a current-value stream (requires {@link Service}). */
|
|
181
|
+
export const invitations = (id: SpaceId): Stream.Stream<readonly Invitation.Flow[], never, Service> =>
|
|
182
|
+
Stream.unwrap(Effect.map(Service, (service) => service.invitations(id)));
|
|
183
|
+
|
|
184
|
+
/** Export a space to an archive (requires {@link Service}). */
|
|
185
|
+
export const exportSpace = (id: SpaceId): Effect.Effect<Archive, SpaceError, Service> =>
|
|
186
|
+
Effect.flatMap(Service, (service) => service.export(id));
|
|
187
|
+
|
|
188
|
+
/** Import a space from an archive (requires {@link Service}). */
|
|
189
|
+
export const importSpace = (
|
|
190
|
+
archive: Archive,
|
|
191
|
+
options?: { tags?: readonly string[] },
|
|
192
|
+
): Effect.Effect<Info, SpaceError, Service> => Effect.flatMap(Service, (service) => service.import(archive, options));
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { BaseError } from '@dxos/errors';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The acting principal lacks the capability required for the operation
|
|
9
|
+
* (e.g. delegating access above their own level, or revoking without causal seniority).
|
|
10
|
+
*/
|
|
11
|
+
export class NotAuthorizedError extends BaseError.extend('NotAuthorizedError', 'Not authorized') {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The referenced principal (individual or group) is not known to the service.
|
|
15
|
+
*/
|
|
16
|
+
export class UnknownPrincipalError extends BaseError.extend('UnknownPrincipalError', 'Unknown principal') {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A signed payload failed signature verification or its id does not match its key.
|
|
20
|
+
*/
|
|
21
|
+
export class InvalidSignatureError extends BaseError.extend('InvalidSignatureError', 'Invalid signature') {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A membership operation references causal predecessors that have not been received.
|
|
25
|
+
*/
|
|
26
|
+
export class MissingDependencyError extends BaseError.extend('MissingDependencyError', 'Missing dependency') {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An identity/device operation (create, recover, profile/device update, device invitation) failed.
|
|
30
|
+
*/
|
|
31
|
+
export class IdentityError extends BaseError.extend('IdentityError', 'Identity operation failed') {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A space operation (create, query, membership, invitation, export/import) failed.
|
|
35
|
+
*/
|
|
36
|
+
export class SpaceError extends BaseError.extend('SpaceError', 'Space operation failed') {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An invitation-lifecycle operation (authenticate, cancel) failed.
|
|
40
|
+
*/
|
|
41
|
+
export class InvitationError extends BaseError.extend('InvitationError', 'Invitation operation failed') {}
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"types": [
|
|
5
|
+
"node"
|
|
6
|
+
]
|
|
7
|
+
},
|
|
8
|
+
"include": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"references": [
|
|
12
|
+
{
|
|
13
|
+
"path": "../../../common/errors"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "../../../common/keys"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|