@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.
Files changed (34) hide show
  1. package/dist/lib/browser/index.mjs +755 -167
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +729 -144
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/types/src/credentials/credential-factory.d.ts +3 -1
  8. package/dist/types/src/credentials/credential-factory.d.ts.map +1 -1
  9. package/dist/types/src/credentials/credential-generator.d.ts +18 -2
  10. package/dist/types/src/credentials/credential-generator.d.ts.map +1 -1
  11. package/dist/types/src/credentials/signing.d.ts.map +1 -1
  12. package/dist/types/src/credentials/verifier.d.ts +1 -1
  13. package/dist/types/src/credentials/verifier.d.ts.map +1 -1
  14. package/dist/types/src/graph/credential-graph.d.ts +152 -0
  15. package/dist/types/src/graph/credential-graph.d.ts.map +1 -0
  16. package/dist/types/src/state-machine/member-state-machine.d.ts +17 -12
  17. package/dist/types/src/state-machine/member-state-machine.d.ts.map +1 -1
  18. package/dist/types/src/state-machine/member-state-machine.test.d.ts +2 -0
  19. package/dist/types/src/state-machine/member-state-machine.test.d.ts.map +1 -0
  20. package/dist/types/src/state-machine/space-state-machine.d.ts +3 -3
  21. package/dist/types/src/state-machine/space-state-machine.d.ts.map +1 -1
  22. package/package.json +16 -16
  23. package/project.json +3 -0
  24. package/src/credentials/credential-factory.ts +9 -1
  25. package/src/credentials/credential-generator.ts +35 -2
  26. package/src/credentials/signing.ts +1 -0
  27. package/src/credentials/verifier.test.ts +25 -0
  28. package/src/credentials/verifier.ts +5 -1
  29. package/src/graph/credential-graph.ts +557 -0
  30. package/src/state-machine/invitation-state-machine.test.ts +1 -1
  31. package/src/state-machine/member-state-machine.test.ts +580 -0
  32. package/src/state-machine/member-state-machine.ts +111 -53
  33. package/src/state-machine/space-state-machine.test.ts +20 -16
  34. 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, type SpaceMember, type ProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
9
- import { type AsyncCallback, Callback, ComplexMap } from '@dxos/util';
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 _creator: MemberInfo | undefined;
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._creator;
42
+ return this._ownerKey && this._hashgraph.getSubjectState(this._ownerKey);
39
43
  }
40
44
 
41
45
  get members(): ReadonlyMap<PublicKey, MemberInfo> {
42
- return this._members;
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 | undefined {
46
- return this._members.get(member)?.assertion.role;
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
- // Ignore duplicate admissions.
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
- const info: MemberInfo = {
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._members.get(credential.subject.id);
92
- if (!member) {
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
- async onRevoked(revoked: Credential, revocation: Credential) {
106
- invariant(revoked.id);
107
- for (const member of this._members.values()) {
108
- if (member.credential.id?.equals(revoked.id)) {
109
- member.removed = true;
110
- break;
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.MEMBER,
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
- expect(Array.from(spaceState.members.values())).toMatchObject([
191
- {
192
- key: identity,
193
- assertion: {
194
- spaceKey: space,
195
- role: SpaceMember.Role.ADMIN,
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
- key: identity2,
200
- assertion: {
201
- spaceKey: space,
202
- role: SpaceMember.Role.MEMBER,
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 onMemberAdmitted = this._members.onMemberAdmitted;
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 key.equals(this._spaceKey) || this._members.getRole(key) === SpaceMember.Role.ADMIN;
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.MEMBER || role === SpaceMember.Role.ADMIN;
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