Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions apps/events/src/routes/playground.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button';
import { assertExhaustive } from '@/lib/assertExhaustive';
import { createFileRoute } from '@tanstack/react-router';
import { Effect } from 'effect';
import * as Schema from 'effect/Schema';
import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace } from 'graph-framework';
import { ResponseMessage, createIdentity, createSpace } from 'graph-framework';
import { ResponseMessage, createSpace } from 'graph-framework';
import { useEffect, useState } from 'react';

const decodeResponseMessage = Schema.decodeUnknownEither(ResponseMessage);
Expand All @@ -12,7 +13,7 @@ export const Route = createFileRoute('/playground')({
component: () => <ChooseAccount />,
});

const App = ({ accountId }: { accountId: string }) => {
const App = ({ accountId, signaturePrivateKey }: { accountId: string; signaturePrivateKey: string }) => {
const [websocketConnection, setWebsocketConnection] = useState<WebSocket>();
const [spaces, setSpaces] = useState<{ id: string }[]>([]);

Expand Down Expand Up @@ -75,9 +76,16 @@ const App = ({ accountId }: { accountId: string }) => {
<>
<div>
<Button
onClick={() => {
const identity = createIdentity();
const spaceEvent = createSpace({ author: identity });
onClick={async () => {
const spaceEvent = await Effect.runPromise(
createSpace({
author: {
encryptionPublicKey: 'TODO',
signaturePrivateKey,
signaturePublicKey: accountId,
},
}),
);
const message: EventMessage = { type: 'event', event: spaceEvent };
websocketConnection?.send(JSON.stringify(message));
}}
Expand Down Expand Up @@ -117,39 +125,49 @@ const App = ({ accountId }: { accountId: string }) => {
};

export const ChooseAccount = () => {
const [accountId, setAccountId] = useState<string | null>();
const [account, setAccount] = useState<{ accountId: string; signaturePrivateKey: string } | null>();

return (
<div>
<h1>Choose account</h1>
<Button
onClick={() => {
setAccountId('abc');
setAccount({
accountId: '0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
signaturePrivateKey: '88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
});
}}
>
`abc`
</Button>
<Button
onClick={() => {
setAccountId('cde');
setAccount({
accountId: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462',
signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15',
});
}}
>
`cde`
</Button>
<Button
onClick={() => {
setAccountId('def');
setAccount({
accountId: '0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2',
signaturePrivateKey: '434518a2c9a665a7c20da086232c818b6c1592e2edfeecab29a40cf5925ca8fe',
});
}}
>
`def`
</Button>
Account: {accountId ? accountId : 'none'}
Account: {account?.accountId ? account.accountId : 'none'}
<hr />
{accountId && (
{account && (
<App
// forcing a remount of the App component when the accountId changes
key={accountId}
accountId={accountId}
key={account.accountId}
accountId={account.accountId}
signaturePrivateKey={account.signaturePrivateKey}
/>
)}
</div>
Expand Down
30 changes: 17 additions & 13 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import cors from 'cors';
import 'dotenv/config';
import { parse } from 'node:url';
import { Schema } from 'effect';
import { Effect, Exit, Schema } from 'effect';
import express from 'express';
import type { ResponseListSpaces, ResponseSpace } from 'graph-framework-messages';
import { RequestMessage } from 'graph-framework-messages';
import type { CreateSpaceEvent } from 'graph-framework-space-events';
import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events';
import type WebSocket from 'ws';
import { WebSocketServer } from 'ws';
import { createSpace } from './handlers/createSpace.js';
Expand All @@ -16,9 +16,9 @@ import { assertExhaustive } from './utils/assertExhaustive.js';

const decodeRequestMessage = Schema.decodeUnknownEither(RequestMessage);

tmpInitAccount('abc');
tmpInitAccount('cde');
tmpInitAccount('def');
tmpInitAccount('0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5');
tmpInitAccount('03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462');
tmpInitAccount('0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2');

const webSocketServer = new WebSocketServer({ noServer: true });
const PORT = process.env.PORT !== undefined ? Number.parseInt(process.env.PORT) : 3030;
Expand Down Expand Up @@ -70,14 +70,18 @@ webSocketServer.on('connection', async (webSocket: WebSocket, request: Request)
case 'event': {
switch (data.event.transaction.type) {
case 'create-space': {
const space = await createSpace({ accountId, event: data.event as CreateSpaceEvent });
const spaceWithEvents = await getSpace({ accountId, spaceId: space.id });
const outgoingMessage: ResponseSpace = {
type: 'space',
id: space.id,
events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)),
};
webSocket.send(JSON.stringify(outgoingMessage));
const applyEventResult = await Effect.runPromiseExit(applyEvent({ event: data.event }));
if (Exit.isSuccess(applyEventResult)) {
const space = await createSpace({ accountId, event: data.event as CreateSpaceEvent });
const spaceWithEvents = await getSpace({ accountId, spaceId: space.id });
const outgoingMessage: ResponseSpace = {
type: 'space',
id: space.id,
events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)),
};
webSocket.send(JSON.stringify(outgoingMessage));
}
// TODO send back error
break;
}
case 'delete-space': {
Expand Down
5 changes: 3 additions & 2 deletions packages/graph-framework-space-events/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"effect": "^3.10.12"
},
"dependencies": {
"uuid": "^11.0.2",
"graph-framework-utils": "workspace:*"
"@noble/curves": "^1.6.0",
"graph-framework-utils": "workspace:*",
"uuid": "^11.0.2"
}
}
36 changes: 36 additions & 0 deletions packages/graph-framework-space-events/src/apply-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { secp256k1 } from '@noble/curves/secp256k1';
import { Cause, Effect, Exit } from 'effect';
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
import { expect, it } from 'vitest';
import { applyEvent } from './apply-event.js';
import { createSpace } from './create-space.js';
import { VerifySignatureError } from './types.js';

it('should fail in case of an invalid signature', async () => {
const author = {
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
encryptionPublicKey: 'encryption',
};

const result = await Effect.runPromiseExit(
Effect.gen(function* () {
const spaceEvent = yield* createSpace({ author });

const emptyTransaction = stringToUint8Array(canonicalize({}));
const signature = secp256k1.sign(emptyTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();

// @ts-expect-error
spaceEvent.author.signature = signature;
return yield* applyEvent({ event: spaceEvent });
}),
);

expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const cause = result.cause;
if (Cause.isFailType(cause)) {
expect(cause.error).toBeInstanceOf(VerifySignatureError);
}
}
});
37 changes: 27 additions & 10 deletions packages/graph-framework-space-events/src/apply-event.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import type { SpaceEvent, SpaceInvitation, SpaceMember, SpaceState } from './types.js';
import { secp256k1 } from '@noble/curves/secp256k1';
import { Effect, Schema } from 'effect';
import type { ParseError } from 'effect/ParseResult';
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
import type { SpaceInvitation, SpaceMember, SpaceState } from './types.js';
import { SpaceEvent, VerifySignatureError } from './types.js';

type Params = {
state?: SpaceState;
event: SpaceEvent;
};

export const applyEvent = ({ state, event: rawEvent }: Params): SpaceState => {
// TODO parse the event
const event = rawEvent;
const decodeSpaceEvent = Schema.decodeUnknownEither(SpaceEvent);

// TODO verify the event
// - verify the signature
// - verify that this event is based on the previous one
// - verify versioning
export const applyEvent = ({
state,
event: rawEvent,
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError> => {
const decodedEvent = decodeSpaceEvent(rawEvent);
if (decodedEvent._tag === 'Left') {
return decodedEvent.left;
}
const event = decodedEvent.right;

const encodedTransaction = stringToUint8Array(canonicalize(event.transaction));
const isValidSignature = secp256k1.verify(event.author.signature, encodedTransaction, event.author.publicKey, {
prehash: true,
});

if (!isValidSignature) {
return Effect.fail(new VerifySignatureError());
}

let id = '';
let members: { [signaturePublicKey: string]: SpaceMember } = {};
Expand Down Expand Up @@ -46,11 +63,11 @@ export const applyEvent = ({ state, event: rawEvent }: Params): SpaceState => {
throw new Error('State is required for all events except create-space');
}

return {
return Effect.succeed({
id,
members,
removedMembers,
invitations,
transactionHash: '', // TODO
};
});
};
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { expect, it } from 'vitest';

import { Effect } from 'effect';
import { applyEvent } from './apply-event.js';
import { createInvitation } from './create-invitation.js';
import { createSpace } from './create-space.js';

it('should create an invitation', () => {
it('should create an invitation', async () => {
const author = {
signaturePublicKey: 'signature',
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
encryptionPublicKey: 'encryption',
};
const spaceEvent = createSpace({ author });
const state = applyEvent({ event: spaceEvent });
const spaceEvent2 = createInvitation({ author, id: state.id });
const state2 = applyEvent({ state, event: spaceEvent2 });

const { spaceEvent2, state2 } = await Effect.runPromise(
Effect.gen(function* () {
const spaceEvent = yield* createSpace({ author });
const state = yield* applyEvent({ event: spaceEvent });
const spaceEvent2 = yield* createInvitation({ author, id: state.id });
const state2 = yield* applyEvent({ state, event: spaceEvent2 });
return {
state2,
spaceEvent2,
};
}),
);
expect(state2).toEqual({
id: state.id,
id: state2.id,
members: {
[author.signaturePublicKey]: {
signaturePublicKey: author.signaturePublicKey,
Expand Down
13 changes: 8 additions & 5 deletions packages/graph-framework-space-events/src/create-invitation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { secp256k1 } from '@noble/curves/secp256k1';
import { Effect } from 'effect';
import { canonicalize, stringToUint8Array } from 'graph-framework-utils';
import type { Author, SpaceEvent } from './types.js';

type Params = {
author: Author;
id: string;
};

export const createInvitation = ({ author, id }: Params): SpaceEvent => {
export const createInvitation = ({ author, id }: Params): Effect.Effect<SpaceEvent, undefined> => {
const transaction = {
type: 'create-invitation' as const,
id,
Expand All @@ -14,14 +17,14 @@ export const createInvitation = ({ author, id }: Params): SpaceEvent => {
signaturePublicKey: '',
encryptionPublicKey: '',
};
// TODO canonicalize, hash and sign the transaction
const signature = '';
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();

return {
return Effect.succeed({
transaction,
author: {
publicKey: author.signaturePublicKey,
signature,
},
};
});
};
19 changes: 13 additions & 6 deletions packages/graph-framework-space-events/src/create-space.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { Effect } from 'effect';
import { expect, it } from 'vitest';

import { applyEvent } from './apply-event.js';
import { createSpace } from './create-space.js';

it('should create a space state', () => {
it('should create a space state', async () => {
const author = {
signaturePublicKey: 'signature',
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
encryptionPublicKey: 'encryption',
};
const spaceEvent = createSpace({ author });
const state = applyEvent({ event: spaceEvent });

const state = await Effect.runPromise(
Effect.gen(function* () {
const spaceEvent = yield* createSpace({ author });
return yield* applyEvent({ event: spaceEvent });
}),
);

expect(state).toEqual({
id: spaceEvent.transaction.id,
id: state.id,
invitations: {},
members: {
[author.signaturePublicKey]: {
Expand Down
15 changes: 8 additions & 7 deletions packages/graph-framework-space-events/src/create-space.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
import { generateId } from 'graph-framework-utils';

import { secp256k1 } from '@noble/curves/secp256k1';
import { Effect } from 'effect';
import { canonicalize, generateId, stringToUint8Array } from 'graph-framework-utils';
import type { Author, SpaceEvent } from './types.js';

type Params = {
author: Author;
};

export const createSpace = ({ author }: Params): SpaceEvent => {
export const createSpace = ({ author }: Params): Effect.Effect<SpaceEvent, undefined> => {
const transaction = {
type: 'create-space' as const,
id: generateId(),
creatorSignaturePublicKey: author.signaturePublicKey,
creatorEncryptionPublicKey: author.encryptionPublicKey,
};
// TODO canonicalize, hash and sign the transaction
const signature = '';
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();

return {
return Effect.succeed({
transaction,
author: {
publicKey: author.signaturePublicKey,
signature,
},
};
});
};
Loading
Loading