Skip to content

Commit 5599b30

Browse files
committed
add accept-invitation
1 parent 3bfe010 commit 5599b30

File tree

16 files changed

+355
-45
lines changed

16 files changed

+355
-45
lines changed

apps/events/src/components/debug-invitations.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { Invitation } from 'graph-framework';
22
import { Button } from './ui/button';
33

4-
export function DebugInvitations({ invitations }: { invitations: Invitation[] }) {
4+
type Props = {
5+
invitations: Invitation[];
6+
accept: (invitation: Invitation) => void;
7+
};
8+
9+
export function DebugInvitations({ invitations, accept }: Props) {
510
return (
611
<ul className="text-xs">
712
{invitations.map((invitation) => {
@@ -10,7 +15,7 @@ export function DebugInvitations({ invitations }: { invitations: Invitation[] })
1015
<pre>{JSON.stringify(invitation, null, 2)}</pre>
1116
<Button
1217
onClick={() => {
13-
alert('TODO');
18+
accept(invitation);
1419
}}
1520
>
1621
Accept

apps/events/src/routes/playground.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
SpaceEvent,
1616
SpaceState,
1717
} from 'graph-framework';
18-
import { ResponseMessage, applyEvent, createInvitation, createSpace } from 'graph-framework';
18+
import { ResponseMessage, acceptInvitation, applyEvent, createInvitation, createSpace } from 'graph-framework';
1919
import { useEffect, useState } from 'react';
2020

2121
const availableAccounts = [
@@ -186,7 +186,27 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP
186186
</Button>
187187
</div>
188188
<h2 className="text-lg">Invitations</h2>
189-
<DebugInvitations invitations={invitations} />
189+
<DebugInvitations
190+
invitations={invitations}
191+
accept={async (invitation) => {
192+
const spaceEvent = await Effect.runPromiseExit(
193+
acceptInvitation({
194+
author: {
195+
signaturePublicKey: accountId,
196+
encryptionPublicKey: 'TODO',
197+
signaturePrivateKey,
198+
},
199+
previousEventHash: invitation.previousEventHash,
200+
}),
201+
);
202+
if (Exit.isFailure(spaceEvent)) {
203+
console.error('Failed to accept invitation', spaceEvent);
204+
return;
205+
}
206+
const message: EventMessage = { type: 'event', event: spaceEvent.value, spaceId: invitation.spaceId };
207+
websocketConnection?.send(JSON.stringify(message));
208+
}}
209+
/>
190210
<h2 className="text-lg">Spaces</h2>
191211
<ul>
192212
{spaces.map((space) => {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
Warnings:
3+
4+
- Added the required column `inviteeAccountId` to the `Invitation` table without a default value. This is not possible if the table is not empty.
5+
6+
*/
7+
-- RedefineTables
8+
PRAGMA defer_foreign_keys=ON;
9+
PRAGMA foreign_keys=OFF;
10+
CREATE TABLE "new_Invitation" (
11+
"id" TEXT NOT NULL PRIMARY KEY,
12+
"spaceId" TEXT NOT NULL,
13+
"accountId" TEXT NOT NULL,
14+
"inviteeAccountId" TEXT NOT NULL,
15+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+
CONSTRAINT "Invitation_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
17+
CONSTRAINT "Invitation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
18+
);
19+
INSERT INTO "new_Invitation" ("accountId", "createdAt", "id", "spaceId") SELECT "accountId", "createdAt", "id", "spaceId" FROM "Invitation";
20+
DROP TABLE "Invitation";
21+
ALTER TABLE "new_Invitation" RENAME TO "Invitation";
22+
CREATE UNIQUE INDEX "Invitation_spaceId_inviteeAccountId_key" ON "Invitation"("spaceId", "inviteeAccountId");
23+
PRAGMA foreign_keys=ON;
24+
PRAGMA defer_foreign_keys=OFF;

apps/server/prisma/schema.prisma

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@ model Account {
3636
}
3737

3838
model Invitation {
39-
id String @id
40-
space Space @relation(fields: [spaceId], references: [id])
41-
spaceId String
42-
account Account @relation(fields: [accountId], references: [id])
43-
accountId String
44-
createdAt DateTime @default(now())
39+
id String @id
40+
space Space @relation(fields: [spaceId], references: [id])
41+
spaceId String
42+
account Account @relation(fields: [accountId], references: [id])
43+
accountId String
44+
inviteeAccountId String
45+
createdAt DateTime @default(now())
46+
47+
@@unique([spaceId, inviteeAccountId])
4548
}

apps/server/src/handlers/applySpaceEvent.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@ export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
1515
}
1616

1717
return await prisma.$transaction(async (transaction) => {
18-
// verify that the account is a member of the space
19-
// TODO verify that the account is a admin of the space
20-
await transaction.space.findUniqueOrThrow({
21-
where: { id: spaceId, members: { some: { id: accountId } } },
22-
});
18+
if (event.transaction.type === 'accept-invitation') {
19+
// verify that the account is the invitee
20+
await transaction.invitation.findFirstOrThrow({
21+
where: { inviteeAccountId: event.author.publicKey },
22+
});
23+
} else {
24+
// verify that the account is a member of the space
25+
// TODO verify that the account is a admin of the space
26+
await transaction.space.findUniqueOrThrow({
27+
where: { id: spaceId, members: { some: { id: accountId } } },
28+
});
29+
}
2330

2431
const lastEvent = await transaction.spaceEvent.findFirstOrThrow({
2532
where: { spaceId },
@@ -38,9 +45,20 @@ export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
3845
id: event.transaction.id,
3946
spaceId,
4047
accountId: event.transaction.signaturePublicKey,
48+
inviteeAccountId: event.transaction.signaturePublicKey,
4149
},
4250
});
4351
}
52+
if (event.transaction.type === 'accept-invitation') {
53+
await transaction.invitation.delete({
54+
where: { spaceId_inviteeAccountId: { spaceId, inviteeAccountId: event.author.publicKey } },
55+
});
56+
57+
await transaction.space.update({
58+
where: { id: spaceId },
59+
data: { members: { connect: { id: event.author.publicKey } } },
60+
});
61+
}
4462

4563
return await transaction.spaceEvent.create({
4664
data: {

apps/server/src/handlers/listInvitations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const listInvitations = async ({ accountId }: Params) => {
1818
include: {
1919
events: {
2020
orderBy: {
21-
counter: 'asc',
21+
counter: 'desc',
2222
},
2323
take: 1,
2424
},

apps/server/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import cors from 'cors';
22
import 'dotenv/config';
3-
import { parse } from 'node:url';
43
import { Effect, Exit, Schema } from 'effect';
54
import express from 'express';
65
import type { ResponseListInvitations, ResponseListSpaces, ResponseSpace } from 'graph-framework-messages';
76
import { RequestMessage } from 'graph-framework-messages';
87
import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events';
8+
import { parse } from 'node:url';
99
import type WebSocket from 'ws';
1010
import { WebSocketServer } from 'ws';
1111
import { applySpaceEvent } from './handlers/applySpaceEvent.js';
@@ -106,6 +106,17 @@ webSocketServer.on('connection', async (webSocket: WebSocket, request: Request)
106106
webSocket.send(JSON.stringify(outgoingMessage));
107107
break;
108108
}
109+
case 'accept-invitation': {
110+
await applySpaceEvent({ accountId, spaceId: data.spaceId, event: data.event });
111+
const spaceWithEvents = await getSpace({ accountId, spaceId: data.spaceId });
112+
const outgoingMessage: ResponseSpace = {
113+
type: 'space',
114+
id: data.spaceId,
115+
events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)),
116+
};
117+
webSocket.send(JSON.stringify(outgoingMessage));
118+
break;
119+
}
109120
}
110121
break;
111122
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { expect, it } from 'vitest';
2+
3+
import { Effect } from 'effect';
4+
import { acceptInvitation } from './accept-invitation.js';
5+
import { applyEvent } from './apply-event.js';
6+
import { createInvitation } from './create-invitation.js';
7+
import { createSpace } from './create-space.js';
8+
9+
const author = {
10+
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
11+
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
12+
encryptionPublicKey: 'encryption',
13+
};
14+
15+
const invitee = {
16+
signaturePublicKey: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462',
17+
signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15',
18+
encryptionPublicKey: 'encryption',
19+
};
20+
21+
it('should accept an invitation', async () => {
22+
const { state3 } = await Effect.runPromise(
23+
Effect.gen(function* () {
24+
const spaceEvent = yield* createSpace({ author });
25+
const state = yield* applyEvent({ event: spaceEvent });
26+
const spaceEvent2 = yield* createInvitation({
27+
author,
28+
previousEventHash: state.lastEventHash,
29+
invitee,
30+
});
31+
const state2 = yield* applyEvent({ state, event: spaceEvent2 });
32+
const spaceEvent3 = yield* acceptInvitation({
33+
previousEventHash: state2.lastEventHash,
34+
author: invitee,
35+
});
36+
const state3 = yield* applyEvent({ state: state2, event: spaceEvent3 });
37+
return {
38+
state3,
39+
spaceEvent3,
40+
};
41+
}),
42+
);
43+
44+
expect(state3.id).toBeTypeOf('string');
45+
expect(state3.invitations).toEqual({});
46+
expect(state3.members).toEqual({
47+
[author.signaturePublicKey]: {
48+
signaturePublicKey: author.signaturePublicKey,
49+
encryptionPublicKey: author.encryptionPublicKey,
50+
role: 'admin',
51+
},
52+
[invitee.signaturePublicKey]: {
53+
signaturePublicKey: invitee.signaturePublicKey,
54+
encryptionPublicKey: invitee.encryptionPublicKey,
55+
role: 'member',
56+
},
57+
});
58+
expect(state3.removedMembers).toEqual({});
59+
expect(state3.lastEventHash).toBeTypeOf('string');
60+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { secp256k1 } from '@noble/curves/secp256k1';
2+
import { Effect } from 'effect';
3+
import { canonicalize, generateId, stringToUint8Array } from 'graph-framework-utils';
4+
import type { AcceptInvitationEvent, Author } from './types.js';
5+
6+
type Params = {
7+
author: Author;
8+
previousEventHash: string;
9+
};
10+
11+
export const acceptInvitation = ({
12+
author,
13+
previousEventHash,
14+
}: Params): Effect.Effect<AcceptInvitationEvent, undefined> => {
15+
const transaction = {
16+
id: generateId(),
17+
type: 'accept-invitation' as const,
18+
previousEventHash,
19+
};
20+
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
21+
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();
22+
23+
return Effect.succeed({
24+
transaction,
25+
author: {
26+
publicKey: author.signaturePublicKey,
27+
signature,
28+
},
29+
});
30+
};

packages/graph-framework-space-events/src/apply-event.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ export const applyEvent = ({
3434
return Effect.fail(new InvalidEventError());
3535
}
3636
if (event.transaction.previousEventHash !== state.lastEventHash) {
37+
console.log('WEEEEE', event.transaction.previousEventHash, state.lastEventHash);
3738
return Effect.fail(new InvalidEventError());
3839
}
3940
}
4041

4142
const encodedTransaction = stringToUint8Array(canonicalize(event.transaction));
43+
4244
const isValidSignature = secp256k1.verify(event.author.signature, encodedTransaction, event.author.publicKey, {
4345
prehash: true,
4446
});
@@ -65,27 +67,58 @@ export const applyEvent = ({
6567
removedMembers = { ...state.removedMembers };
6668
invitations = { ...state.invitations };
6769

68-
if (event.transaction.type === 'delete-space') {
69-
removedMembers = { ...members };
70-
members = {};
71-
invitations = {};
72-
} else if (event.transaction.type === 'create-invitation') {
73-
if (members[event.transaction.signaturePublicKey] !== undefined) {
70+
if (event.transaction.type === 'accept-invitation') {
71+
// is already a member
72+
if (members[event.author.publicKey] !== undefined) {
7473
return Effect.fail(new InvalidEventError());
7574
}
76-
for (const invitation of Object.values(invitations)) {
77-
if (invitation.signaturePublicKey === event.transaction.signaturePublicKey) {
78-
return Effect.fail(new InvalidEventError());
79-
}
75+
76+
// find the invitation
77+
const result = Object.entries(invitations).find(
78+
([, invitation]) => invitation.signaturePublicKey === event.author.publicKey,
79+
);
80+
if (!result) {
81+
return Effect.fail(new InvalidEventError());
8082
}
83+
const [id, invitation] = result;
8184

82-
invitations[event.transaction.id] = {
83-
signaturePublicKey: event.transaction.signaturePublicKey,
84-
encryptionPublicKey: event.transaction.encryptionPublicKey,
85+
members[event.author.publicKey] = {
86+
signaturePublicKey: event.author.publicKey,
87+
encryptionPublicKey: invitation.encryptionPublicKey,
88+
role: 'member',
8589
};
90+
delete invitations[id];
91+
if (removedMembers[event.author.publicKey] !== undefined) {
92+
delete removedMembers[event.author.publicKey];
93+
}
94+
} else {
95+
// check if the author is an admin
96+
if (members[event.author.publicKey]?.role !== 'admin') {
97+
return Effect.fail(new InvalidEventError());
98+
}
99+
100+
if (event.transaction.type === 'delete-space') {
101+
removedMembers = { ...members };
102+
members = {};
103+
invitations = {};
104+
} else if (event.transaction.type === 'create-invitation') {
105+
if (members[event.transaction.signaturePublicKey] !== undefined) {
106+
return Effect.fail(new InvalidEventError());
107+
}
108+
for (const invitation of Object.values(invitations)) {
109+
if (invitation.signaturePublicKey === event.transaction.signaturePublicKey) {
110+
return Effect.fail(new InvalidEventError());
111+
}
112+
}
113+
114+
invitations[event.transaction.id] = {
115+
signaturePublicKey: event.transaction.signaturePublicKey,
116+
encryptionPublicKey: event.transaction.encryptionPublicKey,
117+
};
118+
} else {
119+
throw new Error('State is required for all events except create-space');
120+
}
86121
}
87-
} else {
88-
throw new Error('State is required for all events except create-space');
89122
}
90123

91124
return Effect.succeed({

0 commit comments

Comments
 (0)