@dxos/credentials 0.5.0 → 0.5.1-main.0ba1ecb
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/dist/lib/browser/index.mjs +755 -167
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +729 -144
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/types/src/credentials/credential-factory.d.ts +3 -1
- package/dist/types/src/credentials/credential-factory.d.ts.map +1 -1
- package/dist/types/src/credentials/credential-generator.d.ts +18 -2
- package/dist/types/src/credentials/credential-generator.d.ts.map +1 -1
- package/dist/types/src/credentials/signing.d.ts.map +1 -1
- package/dist/types/src/credentials/verifier.d.ts +1 -1
- package/dist/types/src/credentials/verifier.d.ts.map +1 -1
- package/dist/types/src/graph/credential-graph.d.ts +152 -0
- package/dist/types/src/graph/credential-graph.d.ts.map +1 -0
- package/dist/types/src/state-machine/member-state-machine.d.ts +17 -12
- package/dist/types/src/state-machine/member-state-machine.d.ts.map +1 -1
- package/dist/types/src/state-machine/member-state-machine.test.d.ts +2 -0
- package/dist/types/src/state-machine/member-state-machine.test.d.ts.map +1 -0
- package/dist/types/src/state-machine/space-state-machine.d.ts +3 -3
- package/dist/types/src/state-machine/space-state-machine.d.ts.map +1 -1
- package/package.json +16 -16
- package/project.json +3 -0
- package/src/credentials/credential-factory.ts +9 -1
- package/src/credentials/credential-generator.ts +35 -2
- package/src/credentials/signing.ts +1 -0
- package/src/credentials/verifier.test.ts +25 -0
- package/src/credentials/verifier.ts +5 -1
- package/src/graph/credential-graph.ts +557 -0
- package/src/state-machine/invitation-state-machine.test.ts +1 -1
- package/src/state-machine/member-state-machine.test.ts +580 -0
- package/src/state-machine/member-state-machine.ts +111 -53
- package/src/state-machine/space-state-machine.test.ts +20 -16
- package/src/state-machine/space-state-machine.ts +12 -33
|
@@ -5,16 +5,23 @@
|
|
|
5
5
|
import { invariant } from '@dxos/invariant';
|
|
6
6
|
import { PublicKey } from '@dxos/keys';
|
|
7
7
|
import { log } from '@dxos/log';
|
|
8
|
-
import { type Credential,
|
|
9
|
-
import {
|
|
8
|
+
import { type Credential, SpaceMember, type ProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
9
|
+
import { ComplexMap } from '@dxos/util';
|
|
10
10
|
|
|
11
11
|
import { getCredentialAssertion } from '../credentials';
|
|
12
|
+
import {
|
|
13
|
+
type ChainVertex,
|
|
14
|
+
CredentialGraph,
|
|
15
|
+
type CredentialGraphStateHandler,
|
|
16
|
+
type PathState,
|
|
17
|
+
type StateScope,
|
|
18
|
+
} from '../graph/credential-graph';
|
|
12
19
|
|
|
13
20
|
export interface MemberInfo {
|
|
14
21
|
key: PublicKey;
|
|
22
|
+
role: SpaceMember.Role;
|
|
15
23
|
credential: Credential;
|
|
16
24
|
assertion: SpaceMember;
|
|
17
|
-
removed: boolean;
|
|
18
25
|
profile?: ProfileDocument;
|
|
19
26
|
}
|
|
20
27
|
|
|
@@ -22,28 +29,29 @@ export interface MemberInfo {
|
|
|
22
29
|
* Tracks the list of members (with roles) for the space.
|
|
23
30
|
* Provides a list of admitted feeds.
|
|
24
31
|
*/
|
|
25
|
-
export class MemberStateMachine {
|
|
26
|
-
private
|
|
32
|
+
export class MemberStateMachine implements CredentialGraphStateHandler<SpaceMember, MemberInfo> {
|
|
33
|
+
private _ownerKey: PublicKey | undefined;
|
|
34
|
+
private _memberProfiles = new ComplexMap<PublicKey, ProfileDocument | undefined>(PublicKey.hash);
|
|
35
|
+
private _hashgraph = new CredentialGraph<SpaceMember, MemberInfo>(this);
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
* Member IDENTITY key => info
|
|
30
|
-
*/
|
|
31
|
-
private _members = new ComplexMap<PublicKey, MemberInfo>(PublicKey.hash);
|
|
32
|
-
|
|
33
|
-
readonly onMemberAdmitted = new Callback<AsyncCallback<MemberInfo>>();
|
|
37
|
+
readonly onMemberRoleChanged = this._hashgraph.onSubjectStateChanged;
|
|
34
38
|
|
|
35
39
|
constructor(private readonly _spaceKey: PublicKey) {}
|
|
36
40
|
|
|
37
41
|
get creator(): MemberInfo | undefined {
|
|
38
|
-
return this.
|
|
42
|
+
return this._ownerKey && this._hashgraph.getSubjectState(this._ownerKey);
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
get members(): ReadonlyMap<PublicKey, MemberInfo> {
|
|
42
|
-
return this.
|
|
46
|
+
return this._hashgraph.getState();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get membershipChainHeads(): PublicKey[] {
|
|
50
|
+
return this._hashgraph.getLeafIds();
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
getRole(member: PublicKey): SpaceMember.Role
|
|
46
|
-
return this.
|
|
53
|
+
getRole(member: PublicKey): SpaceMember.Role {
|
|
54
|
+
return this._getRole(this._hashgraph.getGlobalStateScope(), member);
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
@@ -56,59 +64,109 @@ export class MemberStateMachine {
|
|
|
56
64
|
switch (assertion['@type']) {
|
|
57
65
|
case 'dxos.halo.credentials.SpaceMember': {
|
|
58
66
|
invariant(assertion.spaceKey.equals(this._spaceKey));
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (this._members.has(credential.subject.id)) {
|
|
62
|
-
return;
|
|
67
|
+
if (this._ownerKey == null && credential.issuer === this._spaceKey) {
|
|
68
|
+
this._ownerKey = credential.subject.id;
|
|
63
69
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
key: credential.subject.id,
|
|
67
|
-
credential,
|
|
68
|
-
assertion,
|
|
69
|
-
removed: false,
|
|
70
|
-
profile: assertion.profile,
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// NOTE: Assumes the first member processed is the creator.
|
|
74
|
-
if (!this._creator) {
|
|
75
|
-
this._creator = info;
|
|
70
|
+
if (assertion.profile != null) {
|
|
71
|
+
this._memberProfiles.set(credential.subject.id, assertion.profile);
|
|
76
72
|
}
|
|
77
|
-
|
|
78
|
-
this._members.set(credential.subject.id, info);
|
|
79
|
-
log('member added', {
|
|
80
|
-
member: credential.subject.id,
|
|
81
|
-
space: this._spaceKey,
|
|
82
|
-
role: assertion.role,
|
|
83
|
-
profile: assertion.profile,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
await this.onMemberAdmitted.callIfSet(info);
|
|
73
|
+
await this._hashgraph.addVertex(credential, assertion);
|
|
87
74
|
break;
|
|
88
75
|
}
|
|
89
|
-
|
|
90
76
|
case 'dxos.halo.credentials.MemberProfile': {
|
|
91
|
-
const member = this.
|
|
92
|
-
if (
|
|
93
|
-
log.warn('Member not found', { id: credential.subject.id });
|
|
94
|
-
} else {
|
|
77
|
+
const member = this._hashgraph.getSubjectState(credential.subject.id);
|
|
78
|
+
if (member) {
|
|
95
79
|
member.profile = assertion.profile;
|
|
80
|
+
} else {
|
|
81
|
+
log.warn('Member not found', { id: credential.subject.id });
|
|
96
82
|
}
|
|
83
|
+
this._memberProfiles.set(credential.subject.id, assertion.profile);
|
|
97
84
|
break;
|
|
98
85
|
}
|
|
99
|
-
|
|
100
86
|
default:
|
|
101
87
|
throw new Error('Invalid assertion type');
|
|
102
88
|
}
|
|
103
89
|
}
|
|
104
90
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
91
|
+
public createState(credential: Credential, assertion: SpaceMember): MemberInfo {
|
|
92
|
+
const memberKey = credential.subject.id;
|
|
93
|
+
return {
|
|
94
|
+
key: memberKey,
|
|
95
|
+
role: assertion.role,
|
|
96
|
+
credential,
|
|
97
|
+
assertion,
|
|
98
|
+
profile: this._memberProfiles.get(memberKey),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public isUpdateAllowed(scope: StateScope<SpaceMember>, credential: Credential, assertion: SpaceMember) {
|
|
103
|
+
if (assertion.role === SpaceMember.Role.OWNER) {
|
|
104
|
+
return credential!.issuer.equals(this._spaceKey);
|
|
105
|
+
}
|
|
106
|
+
const issuer = credential.issuer;
|
|
107
|
+
const isChangingOwnRole = issuer.equals(credential.subject.id);
|
|
108
|
+
if (isChangingOwnRole) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (issuer.equals(assertion.spaceKey)) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
const issuerRole = this._getRole(scope, issuer);
|
|
115
|
+
return issuerRole === SpaceMember.Role.ADMIN || issuerRole === SpaceMember.Role.OWNER;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public getConflictingPaths(
|
|
119
|
+
paths: PathState<SpaceMember>[],
|
|
120
|
+
update: ChainVertex<SpaceMember>,
|
|
121
|
+
): PathState<SpaceMember>[] {
|
|
122
|
+
// a member can't be an issuer in a concurrent branch if we decided to remove or revoke admin permissions during merge
|
|
123
|
+
if (update.assertion.role !== SpaceMember.Role.REMOVED && update.assertion.role !== SpaceMember.Role.EDITOR) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const memberId = update.credential!.subject.id!;
|
|
127
|
+
return paths.filter((p) => p.forkIssuers.has(memberId));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public tryPickWinningUpdate(
|
|
131
|
+
scope1: StateScope<SpaceMember>,
|
|
132
|
+
update1: Credential,
|
|
133
|
+
scope2: StateScope<SpaceMember>,
|
|
134
|
+
update2: Credential,
|
|
135
|
+
): Credential | null {
|
|
136
|
+
const path1IssuerRole = this._getRole(scope1, update1.issuer);
|
|
137
|
+
const path2IssuerRole = this._getRole(scope2, update2.issuer);
|
|
138
|
+
if ((path2IssuerRole === SpaceMember.Role.OWNER) !== (path1IssuerRole === SpaceMember.Role.OWNER)) {
|
|
139
|
+
log('owner decision used to break the tie');
|
|
140
|
+
return path1IssuerRole === SpaceMember.Role.OWNER ? update1 : update2;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public toLogString(assertion: SpaceMember | undefined): string {
|
|
146
|
+
const role = assertion?.role ?? SpaceMember.Role.REMOVED;
|
|
147
|
+
return Object.entries(SpaceMember.Role).find(([_, value]) => value === role)![0];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public hasStateChanged(s1?: MemberInfo, s2?: MemberInfo): boolean {
|
|
151
|
+
return s1?.role !== s2?.role;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private _getRole(scope: StateScope<SpaceMember>, memberId: PublicKey): SpaceMember.Role {
|
|
155
|
+
if (this._ownerKey?.equals(memberId)) {
|
|
156
|
+
return SpaceMember.Role.OWNER;
|
|
157
|
+
}
|
|
158
|
+
const realRole = scope.state.get(memberId)?.assertion?.role ?? SpaceMember.Role.REMOVED;
|
|
159
|
+
if (scope.stateOverrides != null) {
|
|
160
|
+
const override = scope.stateOverrides.get(memberId);
|
|
161
|
+
if (override != null) {
|
|
162
|
+
log('member role overridden in path', () => ({
|
|
163
|
+
headId: scope.head?.id,
|
|
164
|
+
roleOverride: this.toLogString(override.assertion),
|
|
165
|
+
realRole: this.toLogString(scope.state.get(memberId)?.assertion),
|
|
166
|
+
}));
|
|
167
|
+
return override.assertion.role;
|
|
111
168
|
}
|
|
112
169
|
}
|
|
170
|
+
return realRole;
|
|
113
171
|
}
|
|
114
172
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import expect from 'expect';
|
|
6
6
|
|
|
7
7
|
import { Keyring } from '@dxos/keyring';
|
|
8
|
+
import { type PublicKey } from '@dxos/keys';
|
|
8
9
|
import { AdmittedFeed, type Chain, SpaceMember } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
9
10
|
import { describe, test } from '@dxos/test';
|
|
10
11
|
|
|
@@ -136,7 +137,7 @@ describe('SpaceStateMachine', () => {
|
|
|
136
137
|
),
|
|
137
138
|
).toEqual(true);
|
|
138
139
|
|
|
139
|
-
// Create the space member credential.
|
|
140
|
+
// // Create the space member credential.
|
|
140
141
|
expect(
|
|
141
142
|
await spaceState.process(
|
|
142
143
|
await createCredential({
|
|
@@ -175,7 +176,7 @@ describe('SpaceStateMachine', () => {
|
|
|
175
176
|
assertion: {
|
|
176
177
|
'@type': 'dxos.halo.credentials.SpaceMember',
|
|
177
178
|
spaceKey: space,
|
|
178
|
-
role: SpaceMember.Role.
|
|
179
|
+
role: SpaceMember.Role.EDITOR,
|
|
179
180
|
genesisFeedKey: feed,
|
|
180
181
|
},
|
|
181
182
|
signer: keyring,
|
|
@@ -187,22 +188,25 @@ describe('SpaceStateMachine', () => {
|
|
|
187
188
|
).toEqual(true);
|
|
188
189
|
|
|
189
190
|
expect(spaceState.genesisCredential).toBeDefined();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
const comparator = (m1: { key: PublicKey }, m2: { key: PublicKey }) => m1.key.toHex().localeCompare(m2.key.toHex());
|
|
192
|
+
expect(Array.from(spaceState.members.values()).sort(comparator)).toMatchObject(
|
|
193
|
+
[
|
|
194
|
+
{
|
|
195
|
+
key: identity,
|
|
196
|
+
assertion: {
|
|
197
|
+
spaceKey: space,
|
|
198
|
+
role: SpaceMember.Role.ADMIN,
|
|
199
|
+
},
|
|
196
200
|
},
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
{
|
|
202
|
+
key: identity2,
|
|
203
|
+
assertion: {
|
|
204
|
+
spaceKey: space,
|
|
205
|
+
role: SpaceMember.Role.EDITOR,
|
|
206
|
+
},
|
|
203
207
|
},
|
|
204
|
-
|
|
205
|
-
|
|
208
|
+
].sort(comparator),
|
|
209
|
+
);
|
|
206
210
|
expect(Array.from(spaceState.feeds.values())).toMatchObject([]);
|
|
207
211
|
expect(spaceState.credentials).toHaveLength(3);
|
|
208
212
|
});
|
|
@@ -19,6 +19,7 @@ import { type CredentialProcessor } from '../processor/credential-processor';
|
|
|
19
19
|
|
|
20
20
|
export interface SpaceState {
|
|
21
21
|
readonly members: ReadonlyMap<PublicKey, MemberInfo>;
|
|
22
|
+
readonly membershipChainHeads: PublicKey[];
|
|
22
23
|
readonly feeds: ReadonlyMap<PublicKey, FeedInfo>;
|
|
23
24
|
readonly credentials: Credential[];
|
|
24
25
|
readonly genesisCredential: Credential | undefined;
|
|
@@ -42,8 +43,6 @@ export type CredentialEntry = {
|
|
|
42
43
|
revoked: boolean;
|
|
43
44
|
};
|
|
44
45
|
|
|
45
|
-
const REVOCABLE_CREDENTIALS = ['dxos.halo.credentials.SpaceMember'];
|
|
46
|
-
|
|
47
46
|
/**
|
|
48
47
|
* Validates and processes credentials for a single space.
|
|
49
48
|
* Keeps a list of members and feeds.
|
|
@@ -61,7 +60,7 @@ export class SpaceStateMachine implements SpaceState {
|
|
|
61
60
|
private _credentialProcessors: CredentialConsumer<any>[] = [];
|
|
62
61
|
|
|
63
62
|
readonly onCredentialProcessed = new Callback<AsyncCallback<Credential>>();
|
|
64
|
-
readonly
|
|
63
|
+
readonly onMemberRoleChanged = this._members.onMemberRoleChanged;
|
|
65
64
|
readonly onFeedAdmitted = this._feeds.onFeedAdmitted;
|
|
66
65
|
readonly onDelegatedInvitation = this._invitations.onDelegatedInvitation;
|
|
67
66
|
readonly onDelegatedInvitationRemoved = this._invitations.onDelegatedInvitationRemoved;
|
|
@@ -76,6 +75,10 @@ export class SpaceStateMachine implements SpaceState {
|
|
|
76
75
|
return this._members.members;
|
|
77
76
|
}
|
|
78
77
|
|
|
78
|
+
get membershipChainHeads(): PublicKey[] {
|
|
79
|
+
return this._members.membershipChainHeads;
|
|
80
|
+
}
|
|
81
|
+
|
|
79
82
|
get feeds(): ReadonlyMap<PublicKey, FeedInfo> {
|
|
80
83
|
return this._feeds.feeds;
|
|
81
84
|
}
|
|
@@ -153,24 +156,6 @@ export class SpaceStateMachine implements SpaceState {
|
|
|
153
156
|
|
|
154
157
|
const assertion = getCredentialAssertion(credential);
|
|
155
158
|
switch (assertion['@type']) {
|
|
156
|
-
case 'dxos.halo.credentials.Revocation': {
|
|
157
|
-
if (!this._canRevokeCredentials(credential.issuer)) {
|
|
158
|
-
log.warn(`Space member is not authorized to invite new members: ${credential.issuer}`);
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
const toBeRevoked = this._credentialsById.get(credential.subject.id);
|
|
162
|
-
if (!toBeRevoked) {
|
|
163
|
-
log.warn(`Credential to revoke not found: ${credential.subject.id}`);
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
if (!this._credentialCanBeRevoked(toBeRevoked)) {
|
|
167
|
-
log.warn(`Credential cannot be revoked: ${credential.subject.id}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
toBeRevoked.revoked = true;
|
|
171
|
-
await this._members.onRevoked(toBeRevoked.credential, credential);
|
|
172
|
-
break;
|
|
173
|
-
}
|
|
174
159
|
case 'dxos.halo.credentials.SpaceGenesis': {
|
|
175
160
|
if (this._genesisCredential) {
|
|
176
161
|
log.warn('Space already has a genesis credential.');
|
|
@@ -184,7 +169,6 @@ export class SpaceStateMachine implements SpaceState {
|
|
|
184
169
|
log.warn('Space genesis credential must be issued to space.');
|
|
185
170
|
return false;
|
|
186
171
|
}
|
|
187
|
-
|
|
188
172
|
this._genesisCredential = credential;
|
|
189
173
|
break;
|
|
190
174
|
}
|
|
@@ -262,21 +246,16 @@ export class SpaceStateMachine implements SpaceState {
|
|
|
262
246
|
}
|
|
263
247
|
|
|
264
248
|
private _canInviteNewMembers(key: PublicKey): boolean {
|
|
265
|
-
return
|
|
249
|
+
return (
|
|
250
|
+
key.equals(this._spaceKey) ||
|
|
251
|
+
this._members.getRole(key) === SpaceMember.Role.ADMIN ||
|
|
252
|
+
this._members.getRole(key) === SpaceMember.Role.OWNER
|
|
253
|
+
);
|
|
266
254
|
}
|
|
267
255
|
|
|
268
256
|
private _canAdmitFeeds(key: PublicKey): boolean {
|
|
269
257
|
const role = this._members.getRole(key);
|
|
270
|
-
return role === SpaceMember.Role.
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private _canRevokeCredentials(key: PublicKey): boolean {
|
|
274
|
-
return key.equals(this._spaceKey) || this._members.getRole(key) === SpaceMember.Role.ADMIN;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
private _credentialCanBeRevoked(entry: CredentialEntry) {
|
|
278
|
-
// TODO(dmaretskyi): Prohibit from removing space's creator member credential.
|
|
279
|
-
return REVOCABLE_CREDENTIALS.includes(entry.credential.subject.assertion['@type']);
|
|
258
|
+
return role === SpaceMember.Role.EDITOR || role === SpaceMember.Role.ADMIN || role === SpaceMember.Role.OWNER;
|
|
280
259
|
}
|
|
281
260
|
}
|
|
282
261
|
|