diff --git a/apps/server/prisma/migrations/20241113083927_introduce_counter_and_state_to_space_events/migration.sql b/apps/server/prisma/migrations/20241113083927_introduce_counter_and_state_to_space_events/migration.sql new file mode 100644 index 00000000..40c7bd15 --- /dev/null +++ b/apps/server/prisma/migrations/20241113083927_introduce_counter_and_state_to_space_events/migration.sql @@ -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; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index c8864d39..5d29b900 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -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 { diff --git a/apps/server/src/handlers/applySpaceEvent.ts b/apps/server/src/handlers/applySpaceEvent.ts new file mode 100644 index 00000000..4dfec7fa --- /dev/null +++ b/apps/server/src/handlers/applySpaceEvent.ts @@ -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), + id: event.transaction.id, + state: JSON.stringify(result.value), + }, + }); + }); +} diff --git a/apps/server/src/handlers/createSpace.ts b/apps/server/src/handlers/createSpace.ts index 0f87fb99..b0f0b847 100644 --- a/apps/server/src/handlers/createSpace.ts +++ b/apps/server/src/handlers/createSpace.ts @@ -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 = { @@ -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, diff --git a/apps/server/src/handlers/getSpace.ts b/apps/server/src/handlers/getSpace.ts index 0a9db4dd..b0edd025 100644 --- a/apps/server/src/handlers/getSpace.ts +++ b/apps/server/src/handlers/getSpace.ts @@ -16,7 +16,11 @@ export const getSpace = async ({ spaceId, accountId }: Params) => { }, }, include: { - events: true, + events: { + orderBy: { + counter: 'asc', + }, + }, }, }); }; diff --git a/packages/graph-framework-space-events/package.json b/packages/graph-framework-space-events/package.json index ee713755..359bd77f 100644 --- a/packages/graph-framework-space-events/package.json +++ b/packages/graph-framework-space-events/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", "graph-framework-utils": "workspace:*", "uuid": "^11.0.2" } diff --git a/packages/graph-framework-space-events/src/apply-event.test.ts b/packages/graph-framework-space-events/src/apply-event.test.ts index 6f484ea9..19a9d9bf 100644 --- a/packages/graph-framework-space-events/src/apply-event.test.ts +++ b/packages/graph-framework-space-events/src/apply-event.test.ts @@ -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 = { @@ -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 than createSpace', 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* 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); + } + } +}); diff --git a/packages/graph-framework-space-events/src/apply-event.ts b/packages/graph-framework-space-events/src/apply-event.ts index 22f5896c..8c899503 100644 --- a/packages/graph-framework-space-events/src/apply-event.ts +++ b/packages/graph-framework-space-events/src/apply-event.ts @@ -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; @@ -15,13 +22,22 @@ const decodeSpaceEvent = Schema.decodeUnknownEither(SpaceEvent); export const applyEvent = ({ state, event: rawEvent, -}: Params): Effect.Effect => { +}: Params): Effect.Effect => { 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, @@ -68,6 +84,6 @@ export const applyEvent = ({ members, removedMembers, invitations, - transactionHash: '', // TODO + lastEventHash: hashEvent(event), }); }; diff --git a/packages/graph-framework-space-events/src/create-invitation.test.ts b/packages/graph-framework-space-events/src/create-invitation.test.ts index cf15643a..e983cc8a 100644 --- a/packages/graph-framework-space-events/src/create-invitation.test.ts +++ b/packages/graph-framework-space-events/src/create-invitation.test.ts @@ -16,7 +16,7 @@ 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, @@ -24,22 +24,21 @@ it('should create an invitation', async () => { }; }), ); - 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'); }); diff --git a/packages/graph-framework-space-events/src/create-invitation.ts b/packages/graph-framework-space-events/src/create-invitation.ts index 0b5642c1..c6c5519a 100644 --- a/packages/graph-framework-space-events/src/create-invitation.ts +++ b/packages/graph-framework-space-events/src/create-invitation.ts @@ -6,9 +6,10 @@ import type { Author, SpaceEvent } from './types.js'; type Params = { author: Author; id: string; + previousEventHash: string; }; -export const createInvitation = ({ author, id }: Params): Effect.Effect => { +export const createInvitation = ({ author, id, previousEventHash }: Params): Effect.Effect => { const transaction = { type: 'create-invitation' as const, id, @@ -16,6 +17,7 @@ export const createInvitation = ({ author, id }: Params): Effect.Effect { }), ); - 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'); }); diff --git a/packages/graph-framework-space-events/src/create-space.ts b/packages/graph-framework-space-events/src/create-space.ts index 5b0fd907..e46b1469 100644 --- a/packages/graph-framework-space-events/src/create-space.ts +++ b/packages/graph-framework-space-events/src/create-space.ts @@ -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; @@ -17,11 +17,13 @@ export const createSpace = ({ author }: Params): Effect.Effect { Effect.gen(function* () { const spaceEvent = yield* createSpace({ author }); const state = yield* applyEvent({ event: spaceEvent }); - const spaceEvent2 = yield* deleteSpace({ author, id: state.id }); + const spaceEvent2 = yield* deleteSpace({ author, id: state.id, previousEventHash: state.lastEventHash }); return yield* applyEvent({ state, event: spaceEvent2 }); }), ); - expect(state).toEqual({ - id: state.id, - members: {}, - invitations: {}, - removedMembers: { - [author.signaturePublicKey]: { - signaturePublicKey: author.signaturePublicKey, - encryptionPublicKey: author.encryptionPublicKey, - role: 'admin', - }, + expect(state.id).toBeTypeOf('string'); + expect(state.invitations).toEqual({}); + expect(state.members).toEqual({}); + expect(state.removedMembers).toEqual({ + [author.signaturePublicKey]: { + signaturePublicKey: author.signaturePublicKey, + encryptionPublicKey: author.encryptionPublicKey, + role: 'admin', }, - transactionHash: '', }); + expect(state.lastEventHash).toBeTypeOf('string'); }); diff --git a/packages/graph-framework-space-events/src/delete-space.ts b/packages/graph-framework-space-events/src/delete-space.ts index 53b896a6..3ed3935b 100644 --- a/packages/graph-framework-space-events/src/delete-space.ts +++ b/packages/graph-framework-space-events/src/delete-space.ts @@ -1,26 +1,29 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import { Effect } from 'effect'; import { canonicalize, stringToUint8Array } from 'graph-framework-utils'; -import type { Author, SpaceEvent } from './types.js'; +import type { Author, DeleteSpaceEvent, SpaceEvent } from './types.js'; type Params = { author: Author; id: string; + previousEventHash: string; }; -export const deleteSpace = ({ author, id }: Params): Effect.Effect => { +export const deleteSpace = ({ author, id, previousEventHash }: Params): Effect.Effect => { const transaction = { type: 'delete-space' as const, id, + previousEventHash, }; const encodedTransaction = stringToUint8Array(canonicalize(transaction)); const signature = secp256k1.sign(encodedTransaction, author.signaturePrivateKey, { prehash: true }).toCompactHex(); - return Effect.succeed({ + const event: DeleteSpaceEvent = { transaction, author: { publicKey: author.signaturePublicKey, signature, }, - }); + }; + return Effect.succeed(event); }; diff --git a/packages/graph-framework-space-events/src/hash-event.ts b/packages/graph-framework-space-events/src/hash-event.ts new file mode 100644 index 00000000..30a630ef --- /dev/null +++ b/packages/graph-framework-space-events/src/hash-event.ts @@ -0,0 +1,9 @@ +import { blake3 } from '@noble/hashes/blake3'; +import { bytesToHex } from '@noble/hashes/utils'; +import { canonicalize } from 'graph-framework-utils'; +import type { SpaceEvent } from './types.js'; + +export const hashEvent = (event: SpaceEvent): string => { + const hash = blake3(canonicalize(event)); + return bytesToHex(hash); +}; diff --git a/packages/graph-framework-space-events/src/index.ts b/packages/graph-framework-space-events/src/index.ts index 41e4ba21..f3e2c4ca 100644 --- a/packages/graph-framework-space-events/src/index.ts +++ b/packages/graph-framework-space-events/src/index.ts @@ -2,4 +2,5 @@ export * from './apply-event.js'; export * from './create-invitation.js'; export * from './create-space.js'; export * from './delete-space.js'; +export * from './hash-event.js'; export * from './types.js'; diff --git a/packages/graph-framework-space-events/src/types.ts b/packages/graph-framework-space-events/src/types.ts index 412b34a0..2b5000f6 100644 --- a/packages/graph-framework-space-events/src/types.ts +++ b/packages/graph-framework-space-events/src/types.ts @@ -20,7 +20,7 @@ export const SpaceState = Schema.Struct({ invitations: Schema.Record({ key: Schema.String, value: SpaceInvitation }), members: Schema.Record({ key: Schema.String, value: SpaceMember }), removedMembers: Schema.Record({ key: Schema.String, value: SpaceMember }), - transactionHash: Schema.String, + lastEventHash: Schema.String, }); export type SpaceState = Schema.Schema.Type; @@ -44,6 +44,7 @@ export const DeleteSpaceEvent = Schema.Struct({ transaction: Schema.Struct({ type: Schema.Literal('delete-space'), id: Schema.String, + previousEventHash: Schema.String, }), author: Schema.Struct({ publicKey: Schema.String, @@ -61,6 +62,7 @@ export const CreateInvitationEvent = Schema.Struct({ nonce: Schema.String, signaturePublicKey: Schema.String, encryptionPublicKey: Schema.String, + previousEventHash: Schema.String, }), author: Schema.Struct({ publicKey: Schema.String, @@ -85,3 +87,7 @@ export type Author = Schema.Schema.Type; export class VerifySignatureError { readonly _tag = 'VerifySignatureError'; } + +export class InvalidEventError { + readonly _tag = 'InvalidEventError'; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ae5742..0d378504 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,6 +317,9 @@ importers: '@noble/curves': specifier: ^1.6.0 version: 1.6.0 + '@noble/hashes': + specifier: ^1.5.0 + version: 1.5.0 graph-framework-utils: specifier: workspace:* version: link:../graph-framework-utils