Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
Warnings:

- Added the required column `counter` to the `SpaceEvent` table without a default value. This is not possible if the table is not empty.
- Added the required column `state` to the `SpaceEvent` table without a default value. This is not possible if the table is not empty.

*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_SpaceEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"event" TEXT NOT NULL,
"state" TEXT NOT NULL,
"counter" INTEGER NOT NULL,
"spaceId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SpaceEvent_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_SpaceEvent" ("event", "id", "spaceId") SELECT "event", "id", "spaceId" FROM "SpaceEvent";
DROP TABLE "SpaceEvent";
ALTER TABLE "new_SpaceEvent" RENAME TO "SpaceEvent";
CREATE UNIQUE INDEX "SpaceEvent_spaceId_counter_key" ON "SpaceEvent"("spaceId", "counter");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
13 changes: 9 additions & 4 deletions apps/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ datasource db {
}

model SpaceEvent {
id String @id
event String
space Space @relation(fields: [spaceId], references: [id])
spaceId String
id String @id
event String
state String
counter Int
space Space @relation(fields: [spaceId], references: [id])
spaceId String
createdAt DateTime @default(now())

@@unique([spaceId, counter])
}

model Space {
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/handlers/applySpaceEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Effect, Exit } from 'effect';
import type { SpaceEvent } from 'graph-framework-space-events';
import { applyEvent } from 'graph-framework-space-events';
import { prisma } from '../prisma.js';

type Params = {
accountId: string;
spaceId: string;
event: SpaceEvent;
};

export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
return await prisma.$transaction(async (transaction) => {
if (event.transaction.type === 'create-space') {
throw new Error('applySpaceEvent does not support create-space events.');
}

// verify that the account is a member of the space
// TODO verify that the account is a admin of the space
await transaction.space.findUniqueOrThrow({
where: { id: spaceId, members: { some: { id: accountId } } },
});

const lastEvent = await transaction.spaceEvent.findFirstOrThrow({
where: { spaceId },
orderBy: { counter: 'desc' },
});

const result = await Effect.runPromiseExit(applyEvent({ event }));
if (Exit.isFailure(result)) {
throw new Error('Invalid event');
}

return await transaction.spaceEvent.create({
data: {
spaceId,
counter: lastEvent.counter + 1,
event: JSON.stringify(event),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use canonicalize here? Not sure it is necessary, or if we just need the JSON event object

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case we need to JSON.parse the event to forward it to other participants

id: event.transaction.id,
state: JSON.stringify(result.value),
},
});
});
}
10 changes: 9 additions & 1 deletion apps/server/src/handlers/createSpace.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CreateSpaceEvent } from 'graph-framework-space-events';
import { Effect, Exit } from 'effect';
import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events';
import { prisma } from '../prisma.js';

type Params = {
Expand All @@ -7,10 +8,17 @@ type Params = {
};

export const createSpace = async ({ accountId, event }: Params) => {
const result = await Effect.runPromiseExit(applyEvent({ event }));
if (Exit.isFailure(result)) {
throw new Error('Invalid event');
}

return await prisma.spaceEvent.create({
data: {
event: JSON.stringify(event),
id: event.transaction.id,
counter: 0,
state: JSON.stringify(result.value),
space: {
create: {
id: event.transaction.id,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/handlers/getSpace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export const getSpace = async ({ spaceId, accountId }: Params) => {
},
},
include: {
events: true,
events: {
orderBy: {
counter: 'asc',
},
},
},
});
};
1 change: 1 addition & 0 deletions packages/graph-framework-space-events/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"graph-framework-utils": "workspace:*",
"uuid": "^11.0.2"
}
Expand Down
58 changes: 57 additions & 1 deletion packages/graph-framework-space-events/src/apply-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ 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 { createInvitation } from './create-invitation.js';
import { createSpace } from './create-space.js';
import { VerifySignatureError } from './types.js';
import { InvalidEventError, VerifySignatureError } from './types.js';

it('should fail in case of an invalid signature', async () => {
const author = {
Expand Down Expand Up @@ -34,3 +35,58 @@ it('should fail in case of an invalid signature', async () => {
}
}
});

it('should fail in case state is not provided for an event other thant createSpace', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick. typo: thant -> than

const author = {
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
encryptionPublicKey: 'encryption',
};

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

const spaceEvent2 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash });
return yield* applyEvent({ event: spaceEvent2 });
}),
);

expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const cause = result.cause;
if (Cause.isFailType(cause)) {
expect(cause.error).toBeInstanceOf(InvalidEventError);
}
}
});

it('should fail in case of an event is applied that is not based on the previous event', async () => {
const author = {
signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b',
signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b',
encryptionPublicKey: 'encryption',
};

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

const spaceEvent2 = yield* createSpace({ author });
const state2 = yield* applyEvent({ state, event: spaceEvent2 });

const spaceEvent3 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash });
return yield* applyEvent({ state: state2, event: spaceEvent3 });
}),
);

expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const cause = result.cause;
if (Cause.isFailType(cause)) {
expect(cause.error).toBeInstanceOf(InvalidEventError);
}
}
});
24 changes: 20 additions & 4 deletions packages/graph-framework-space-events/src/apply-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ 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';
import { hashEvent } from './hash-event.js';
import {
InvalidEventError,
SpaceEvent,
type SpaceInvitation,
type SpaceMember,
type SpaceState,
VerifySignatureError,
} from './types.js';

type Params = {
state?: SpaceState;
Expand All @@ -15,13 +22,22 @@ const decodeSpaceEvent = Schema.decodeUnknownEither(SpaceEvent);
export const applyEvent = ({
state,
event: rawEvent,
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError> => {
}: Params): Effect.Effect<SpaceState, ParseError | VerifySignatureError | InvalidEventError> => {
const decodedEvent = decodeSpaceEvent(rawEvent);
if (decodedEvent._tag === 'Left') {
return decodedEvent.left;
}
const event = decodedEvent.right;

if (event.transaction.type !== 'create-space') {
if (state === undefined) {
return Effect.fail(new InvalidEventError());
}
if (event.transaction.previousEventHash !== state.lastEventHash) {
return Effect.fail(new InvalidEventError());
}
}

const encodedTransaction = stringToUint8Array(canonicalize(event.transaction));
const isValidSignature = secp256k1.verify(event.author.signature, encodedTransaction, event.author.publicKey, {
prehash: true,
Expand Down Expand Up @@ -68,6 +84,6 @@ export const applyEvent = ({
members,
removedMembers,
invitations,
transactionHash: '', // TODO
lastEventHash: hashEvent(event),
});
};
31 changes: 15 additions & 16 deletions packages/graph-framework-space-events/src/create-invitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,29 @@ it('should create an invitation', async () => {
Effect.gen(function* () {
const spaceEvent = yield* createSpace({ author });
const state = yield* applyEvent({ event: spaceEvent });
const spaceEvent2 = yield* createInvitation({ author, id: state.id });
const spaceEvent2 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash });
const state2 = yield* applyEvent({ state, event: spaceEvent2 });
return {
state2,
spaceEvent2,
};
}),
);
expect(state2).toEqual({
id: state2.id,
members: {
[author.signaturePublicKey]: {
signaturePublicKey: author.signaturePublicKey,
encryptionPublicKey: author.encryptionPublicKey,
role: 'admin',
},

expect(state2.id).toBeTypeOf('string');
expect(state2.invitations).toEqual({
[spaceEvent2.transaction.id]: {
signaturePublicKey: '',
encryptionPublicKey: '',
},
removedMembers: {},
invitations: {
[spaceEvent2.transaction.id]: {
signaturePublicKey: '',
encryptionPublicKey: '',
},
});
expect(state2.members).toEqual({
[author.signaturePublicKey]: {
signaturePublicKey: author.signaturePublicKey,
encryptionPublicKey: author.encryptionPublicKey,
role: 'admin',
},
transactionHash: '',
});
expect(state2.removedMembers).toEqual({});
expect(state2.lastEventHash).toBeTypeOf('string');
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import type { Author, SpaceEvent } from './types.js';
type Params = {
author: Author;
id: string;
previousEventHash: string;
};

export const createInvitation = ({ author, id }: Params): Effect.Effect<SpaceEvent, undefined> => {
export const createInvitation = ({ author, id, previousEventHash }: Params): Effect.Effect<SpaceEvent, undefined> => {
const transaction = {
type: 'create-invitation' as const,
id,
ciphertext: '',
nonce: '',
signaturePublicKey: '',
encryptionPublicKey: '',
previousEventHash,
};
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();
Expand Down
20 changes: 9 additions & 11 deletions packages/graph-framework-space-events/src/create-space.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,15 @@ it('should create a space state', async () => {
}),
);

expect(state).toEqual({
id: state.id,
invitations: {},
members: {
[author.signaturePublicKey]: {
signaturePublicKey: author.signaturePublicKey,
encryptionPublicKey: author.encryptionPublicKey,
role: 'admin',
},
expect(state.id).toBeTypeOf('string');
expect(state.invitations).toEqual({});
expect(state.members).toEqual({
[author.signaturePublicKey]: {
signaturePublicKey: author.signaturePublicKey,
encryptionPublicKey: author.encryptionPublicKey,
role: 'admin',
},
removedMembers: {},
transactionHash: '',
});
expect(state.removedMembers).toEqual({});
expect(state.lastEventHash).toBeTypeOf('string');
});
8 changes: 5 additions & 3 deletions packages/graph-framework-space-events/src/create-space.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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';
import type { Author, CreateSpaceEvent, SpaceEvent } from './types.js';

type Params = {
author: Author;
Expand All @@ -17,11 +17,13 @@ export const createSpace = ({ author }: Params): Effect.Effect<SpaceEvent, undef
const encodedTransaction = stringToUint8Array(canonicalize(transaction));
const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex();

return Effect.succeed({
const event: CreateSpaceEvent = {
transaction,
author: {
publicKey: author.signaturePublicKey,
signature,
},
});
};

return Effect.succeed(event);
};
Loading
Loading