diff --git a/apps/events/src/components/InboxCard.tsx b/apps/events/src/components/InboxCard.tsx new file mode 100644 index 00000000..da12b782 --- /dev/null +++ b/apps/events/src/components/InboxCard.tsx @@ -0,0 +1,54 @@ +import { useExternalAccountInbox } from '@graphprotocol/hypergraph-react'; +import { useState } from 'react'; + +interface InboxCardProps { + accountId: string; + inboxId: string; +} + +export function InboxCard({ accountId, inboxId }: InboxCardProps) { + const [message, setMessage] = useState(''); + const { loading, error, sendMessage, isPublic, authPolicy } = useExternalAccountInbox(accountId, inboxId); + + const handleSendMessage = async () => { + if (!message.trim()) return; + + try { + await sendMessage(message.trim()); + setMessage(''); // Clear the input after sending + } catch (err) { + console.error('Failed to send message:', err); + } + }; + + return ( +
+

Inbox: {inboxId}

+
+
{isPublic ? 'Public' : 'Private'} inbox
+
Auth Policy: {authPolicy}
+
+ +
+ setMessage(e.target.value)} + placeholder="Type a message..." + className="flex-1 px-3 py-2 border rounded" + disabled={loading} + /> + +
+ + {error &&
{error.message}
} +
+ ); +} diff --git a/apps/events/src/components/SpaceChat.tsx b/apps/events/src/components/SpaceChat.tsx new file mode 100644 index 00000000..790c5844 --- /dev/null +++ b/apps/events/src/components/SpaceChat.tsx @@ -0,0 +1,71 @@ +import { useOwnSpaceInbox } from '@graphprotocol/hypergraph-react'; +import { useState } from 'react'; +import { Button } from './ui/button'; + +interface SpaceChatProps { + spaceId: string; +} + +export function SpaceChat({ spaceId }: SpaceChatProps) { + const [message, setMessage] = useState(''); + + // This will create the inbox if it doesn't exist, or use the first inbox in the space + const { messages, error, sendMessage, loading } = useOwnSpaceInbox({ + spaceId, + autoCreate: true, + }); + + if (loading) { + return
Creating space chat...
; + } + + const handleSendMessage = async () => { + if (!message.trim()) return; + + try { + await sendMessage(message.trim()); + setMessage(''); // Clear the input after sending + } catch (err) { + console.error('Failed to send message:', err); + } + }; + + return ( +
+

Space Chat

+ +
+ {/* Messages */} +
+ {messages?.map((msg) => ( +
+
+
From: {msg.authorAccountId?.substring(0, 6) || 'Anonymous'}
+
{new Date(msg.createdAt).toLocaleString()}
+
+
{msg.plaintext}
+
+ ))} + {messages?.length === 0 &&
No messages yet
} +
+ + {/* Input */} +
+ setMessage(e.target.value)} + placeholder="Type a message..." + className="flex-1 px-3 py-2 border rounded" + disabled={loading} + /> + +
+ + {error &&
{error.message}
} +
+
+ ); +} diff --git a/apps/events/src/routeTree.gen.ts b/apps/events/src/routeTree.gen.ts index 4894b762..2c1dd184 100644 --- a/apps/events/src/routeTree.gen.ts +++ b/apps/events/src/routeTree.gen.ts @@ -16,6 +16,8 @@ import { Route as rootRoute } from './routes/__root' import { Route as IndexImport } from './routes/index' import { Route as SpaceSpaceIdImport } from './routes/space/$spaceId' import { Route as SettingsExportWalletImport } from './routes/settings/export-wallet' +import { Route as FriendsAccountIdImport } from './routes/friends/$accountId' +import { Route as AccountInboxInboxIdImport } from './routes/account-inbox/$inboxId' // Create Virtual Routes @@ -47,6 +49,18 @@ const SettingsExportWalletRoute = SettingsExportWalletImport.update({ getParentRoute: () => rootRoute, } as any) +const FriendsAccountIdRoute = FriendsAccountIdImport.update({ + id: '/friends/$accountId', + path: '/friends/$accountId', + getParentRoute: () => rootRoute, +} as any) + +const AccountInboxInboxIdRoute = AccountInboxInboxIdImport.update({ + id: '/account-inbox/$inboxId', + path: '/account-inbox/$inboxId', + getParentRoute: () => rootRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -65,6 +79,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginLazyImport parentRoute: typeof rootRoute } + '/account-inbox/$inboxId': { + id: '/account-inbox/$inboxId' + path: '/account-inbox/$inboxId' + fullPath: '/account-inbox/$inboxId' + preLoaderRoute: typeof AccountInboxInboxIdImport + parentRoute: typeof rootRoute + } + '/friends/$accountId': { + id: '/friends/$accountId' + path: '/friends/$accountId' + fullPath: '/friends/$accountId' + preLoaderRoute: typeof FriendsAccountIdImport + parentRoute: typeof rootRoute + } '/settings/export-wallet': { id: '/settings/export-wallet' path: '/settings/export-wallet' @@ -87,6 +115,8 @@ declare module '@tanstack/react-router' { export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginLazyRoute + '/account-inbox/$inboxId': typeof AccountInboxInboxIdRoute + '/friends/$accountId': typeof FriendsAccountIdRoute '/settings/export-wallet': typeof SettingsExportWalletRoute '/space/$spaceId': typeof SpaceSpaceIdRoute } @@ -94,6 +124,8 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginLazyRoute + '/account-inbox/$inboxId': typeof AccountInboxInboxIdRoute + '/friends/$accountId': typeof FriendsAccountIdRoute '/settings/export-wallet': typeof SettingsExportWalletRoute '/space/$spaceId': typeof SpaceSpaceIdRoute } @@ -102,19 +134,35 @@ export interface FileRoutesById { __root__: typeof rootRoute '/': typeof IndexRoute '/login': typeof LoginLazyRoute + '/account-inbox/$inboxId': typeof AccountInboxInboxIdRoute + '/friends/$accountId': typeof FriendsAccountIdRoute '/settings/export-wallet': typeof SettingsExportWalletRoute '/space/$spaceId': typeof SpaceSpaceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' | '/settings/export-wallet' | '/space/$spaceId' + fullPaths: + | '/' + | '/login' + | '/account-inbox/$inboxId' + | '/friends/$accountId' + | '/settings/export-wallet' + | '/space/$spaceId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' | '/settings/export-wallet' | '/space/$spaceId' + to: + | '/' + | '/login' + | '/account-inbox/$inboxId' + | '/friends/$accountId' + | '/settings/export-wallet' + | '/space/$spaceId' id: | '__root__' | '/' | '/login' + | '/account-inbox/$inboxId' + | '/friends/$accountId' | '/settings/export-wallet' | '/space/$spaceId' fileRoutesById: FileRoutesById @@ -123,6 +171,8 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute LoginLazyRoute: typeof LoginLazyRoute + AccountInboxInboxIdRoute: typeof AccountInboxInboxIdRoute + FriendsAccountIdRoute: typeof FriendsAccountIdRoute SettingsExportWalletRoute: typeof SettingsExportWalletRoute SpaceSpaceIdRoute: typeof SpaceSpaceIdRoute } @@ -130,6 +180,8 @@ export interface RootRouteChildren { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginLazyRoute: LoginLazyRoute, + AccountInboxInboxIdRoute: AccountInboxInboxIdRoute, + FriendsAccountIdRoute: FriendsAccountIdRoute, SettingsExportWalletRoute: SettingsExportWalletRoute, SpaceSpaceIdRoute: SpaceSpaceIdRoute, } @@ -146,6 +198,8 @@ export const routeTree = rootRoute "children": [ "/", "/login", + "/account-inbox/$inboxId", + "/friends/$accountId", "/settings/export-wallet", "/space/$spaceId" ] @@ -156,6 +210,12 @@ export const routeTree = rootRoute "/login": { "filePath": "login.lazy.tsx" }, + "/account-inbox/$inboxId": { + "filePath": "account-inbox/$inboxId.tsx" + }, + "/friends/$accountId": { + "filePath": "friends/$accountId.tsx" + }, "/settings/export-wallet": { "filePath": "settings/export-wallet.tsx" }, diff --git a/apps/events/src/routes/account-inbox/$inboxId.tsx b/apps/events/src/routes/account-inbox/$inboxId.tsx new file mode 100644 index 00000000..da40831d --- /dev/null +++ b/apps/events/src/routes/account-inbox/$inboxId.tsx @@ -0,0 +1,50 @@ +import { useHypergraphAuth, useOwnAccountInbox } from '@graphprotocol/hypergraph-react'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/account-inbox/$inboxId')({ + component: RouteComponent, +}); + +function RouteComponent() { + const { inboxId } = Route.useParams(); + const { identity } = useHypergraphAuth(); + + // Ensure we have an authenticated user + if (!identity?.accountId) { + return
Please login to view your inbox
; + } + + const { messages, loading, error } = useOwnAccountInbox(inboxId); + + if (loading) { + return
Loading inbox messages...
; + } + + if (error) { + return
Error: {error.message}
; + } + + if (!messages) { + return
Inbox not found
; + } + + return ( +
+

Inbox Messages

+
+ {messages.map((message) => ( +
+
{message.plaintext}
+
+
{new Date(message.createdAt).toLocaleString()}
+ {message.authorAccountId &&
From: {message.authorAccountId}
} +
+
+ ))} + {messages.length === 0 && ( +
No messages in this inbox
+ )} +
+
+ ); +} diff --git a/apps/events/src/routes/friends/$accountId.tsx b/apps/events/src/routes/friends/$accountId.tsx new file mode 100644 index 00000000..0ccece6c --- /dev/null +++ b/apps/events/src/routes/friends/$accountId.tsx @@ -0,0 +1,36 @@ +import { usePublicAccountInboxes } from '@graphprotocol/hypergraph-react'; +import { createFileRoute } from '@tanstack/react-router'; +import { InboxCard } from '../../components/InboxCard'; + +export const Route = createFileRoute('/friends/$accountId')({ + component: RouteComponent, +}); + +function RouteComponent() { + const { accountId } = Route.useParams(); + const { publicInboxes, loading, error } = usePublicAccountInboxes(accountId); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
Error: {error.message}
; + } + + if (publicInboxes.length === 0) { + return
No public inboxes found
; + } + + return ( +
+

Friend's Public Inboxes: {accountId}

+ +
+ {publicInboxes.map((inbox: { inboxId: string }) => ( + + ))} +
+
+ ); +} diff --git a/apps/events/src/routes/index.tsx b/apps/events/src/routes/index.tsx index ffcf4192..520588c6 100644 --- a/apps/events/src/routes/index.tsx +++ b/apps/events/src/routes/index.tsx @@ -1,11 +1,12 @@ -import { store } from '@graphprotocol/hypergraph'; -import { useHypergraphApp } from '@graphprotocol/hypergraph-react'; +import { Messages, store } from '@graphprotocol/hypergraph'; +import { useHypergraphApp, useHypergraphAuth } from '@graphprotocol/hypergraph-react'; import { Link, createFileRoute } from '@tanstack/react-router'; import { useSelector } from '@xstate/store/react'; import { useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { availableAccounts } from '@/lib/availableAccounts'; export const Route = createFileRoute('/')({ component: Index, @@ -13,7 +14,19 @@ export const Route = createFileRoute('/')({ function Index() { const spaces = useSelector(store, (state) => state.context.spaces); - const { createSpace, listSpaces, listInvitations, invitations, acceptInvitation, loading } = useHypergraphApp(); + const accountInboxes = useSelector(store, (state) => state.context.accountInboxes); + const { + createSpace, + listSpaces, + listInvitations, + invitations, + acceptInvitation, + createAccountInbox, + getOwnAccountInboxes, + loading, + } = useHypergraphApp(); + + const { identity } = useHypergraphAuth(); console.log('Home page', { loading }); @@ -21,8 +34,9 @@ function Index() { if (!loading) { listSpaces(); listInvitations(); + getOwnAccountInboxes(); } - }, [listSpaces, listInvitations, loading]); + }, [listSpaces, listInvitations, getOwnAccountInboxes, loading]); if (loading) { return
Loading …
; @@ -85,6 +99,57 @@ function Index() { ); })} + +
+

Account Inboxes

+ + +
+
+

Friends

+ +
); } diff --git a/apps/events/src/routes/space/$spaceId.tsx b/apps/events/src/routes/space/$spaceId.tsx index 7e5551ec..4fcef8e1 100644 --- a/apps/events/src/routes/space/$spaceId.tsx +++ b/apps/events/src/routes/space/$spaceId.tsx @@ -2,15 +2,16 @@ import { store } from '@graphprotocol/hypergraph'; import { HypergraphSpaceProvider, useHypergraphApp } from '@graphprotocol/hypergraph-react'; import { createFileRoute } from '@tanstack/react-router'; import { useSelector } from '@xstate/store/react'; +import { useEffect, useState } from 'react'; +import { getAddress } from 'viem'; +import { SpaceChat } from '@/components/SpaceChat'; import { DevTool } from '@/components/dev-tool'; import { Todos } from '@/components/todos'; import { TodosReadOnly } from '@/components/todos-read-only'; import { Button } from '@/components/ui/button'; import { Users } from '@/components/users'; import { availableAccounts } from '@/lib/availableAccounts'; -import { useEffect, useState } from 'react'; -import { getAddress } from 'viem'; export const Route = createFileRoute('/space/$spaceId')({ component: Space, @@ -19,17 +20,18 @@ export const Route = createFileRoute('/space/$spaceId')({ function Space() { const { spaceId } = Route.useParams(); const spaces = useSelector(store, (state) => state.context.spaces); - const { subscribeToSpace, inviteToSpace, loading } = useHypergraphApp(); + const { subscribeToSpace, inviteToSpace, loading: appLoading } = useHypergraphApp(); + useEffect(() => { - if (!loading) { + if (!appLoading) { subscribeToSpace({ spaceId }); } - }, [loading, subscribeToSpace, spaceId]); + }, [appLoading, subscribeToSpace, spaceId]); const [show2ndTodos, setShow2ndTodos] = useState(false); const space = spaces.find((space) => space.id === spaceId); - if (loading) { + if (appLoading) { return
Loading …
; } @@ -44,23 +46,24 @@ function Space() { {show2ndTodos && } + + +

Invite people

- {availableAccounts.map((invitee) => { - return ( - - ); - })} + {availableAccounts.map((invitee) => ( + + ))}
diff --git a/apps/server/prisma/migrations/20250428155829_add_inboxes/migration.sql b/apps/server/prisma/migrations/20250428155829_add_inboxes/migration.sql new file mode 100644 index 00000000..5f9a5ff2 --- /dev/null +++ b/apps/server/prisma/migrations/20250428155829_add_inboxes/migration.sql @@ -0,0 +1,50 @@ +-- CreateTable +CREATE TABLE "SpaceInbox" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceId" TEXT NOT NULL, + "isPublic" BOOLEAN NOT NULL, + "authPolicy" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "encryptedSecretKey" TEXT NOT NULL, + "spaceEventId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SpaceInbox_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "SpaceInbox_spaceEventId_fkey" FOREIGN KEY ("spaceEventId") REFERENCES "SpaceEvent" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SpaceInboxMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceInboxId" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "signatureHex" TEXT, + "signatureRecovery" INTEGER, + "authorAccountId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SpaceInboxMessage_spaceInboxId_fkey" FOREIGN KEY ("spaceInboxId") REFERENCES "SpaceInbox" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AccountInbox" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "isPublic" BOOLEAN NOT NULL, + "authPolicy" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "signatureHex" TEXT NOT NULL, + "signatureRecovery" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AccountInbox_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AccountInboxMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountInboxId" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "signatureHex" TEXT, + "signatureRecovery" INTEGER, + "authorAccountId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AccountInboxMessage_accountInboxId_fkey" FOREIGN KEY ("accountInboxId") REFERENCES "AccountInbox" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 1536ebfa..d2314bb9 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -13,13 +13,14 @@ datasource db { } model SpaceEvent { - id String @id - event String - state String - counter Int - space Space @relation(fields: [spaceId], references: [id]) - spaceId String - createdAt DateTime @default(now()) + id String @id + event String + state String + counter Int + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + createdAt DateTime @default(now()) + inboxes SpaceInbox[] @@unique([spaceId, counter]) } @@ -31,6 +32,7 @@ model Space { invitations Invitation[] keys SpaceKey[] updates Update[] + inboxes SpaceInbox[] } model SpaceKey { @@ -53,6 +55,31 @@ model SpaceKeyBox { createdAt DateTime @default(now()) } +model SpaceInbox { + id String @id + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + isPublic Boolean + authPolicy String + encryptionPublicKey String + encryptedSecretKey String + spaceEvent SpaceEvent @relation(fields: [spaceEventId], references: [id]) + spaceEventId String + messages SpaceInboxMessage[] + createdAt DateTime @default(now()) +} + +model SpaceInboxMessage { + id String @id @default(uuid(4)) + spaceInbox SpaceInbox @relation(fields: [spaceInboxId], references: [id]) + spaceInboxId String + ciphertext String + signatureHex String? + signatureRecovery Int? + authorAccountId String? + createdAt DateTime @default(now()) +} + model Account { id String @id spaces Space[] @@ -63,10 +90,35 @@ model Account { sessionToken String? sessionTokenExpires DateTime? updates Update[] + inboxes AccountInbox[] @@index([sessionToken]) } +model AccountInbox { + id String @id + account Account @relation(fields: [accountId], references: [id]) + accountId String + isPublic Boolean + authPolicy String + encryptionPublicKey String + signatureHex String + signatureRecovery Int + messages AccountInboxMessage[] + createdAt DateTime @default(now()) +} + +model AccountInboxMessage { + id String @id @default(uuid(7)) + accountInbox AccountInbox @relation(fields: [accountInboxId], references: [id]) + accountInboxId String + ciphertext String + signatureHex String? + signatureRecovery Int? + authorAccountId String? + createdAt DateTime @default(now()) +} + model Invitation { id String @id space Space @relation(fields: [spaceId], references: [id]) diff --git a/apps/server/src/handlers/applySpaceEvent.ts b/apps/server/src/handlers/applySpaceEvent.ts index c2bf7c21..2a94f8cc 100644 --- a/apps/server/src/handlers/applySpaceEvent.ts +++ b/apps/server/src/handlers/applySpaceEvent.ts @@ -18,7 +18,7 @@ export async function applySpaceEvent({ accountId, spaceId, event, keyBoxes }: P throw new Error('applySpaceEvent does not support create-space events.'); } - return await prisma.$transaction(async (transaction) => { + await prisma.$transaction(async (transaction) => { if (event.transaction.type === 'accept-invitation') { // verify that the account is the invitee await transaction.invitation.findFirstOrThrow({ @@ -96,7 +96,7 @@ export async function applySpaceEvent({ accountId, spaceId, event, keyBoxes }: P }); } - return await transaction.spaceEvent.create({ + await transaction.spaceEvent.create({ data: { spaceId, counter: lastEvent.counter + 1, @@ -105,5 +105,19 @@ export async function applySpaceEvent({ accountId, spaceId, event, keyBoxes }: P state: JSON.stringify(result.value), }, }); + + if (event.transaction.type === 'create-space-inbox') { + await transaction.spaceInbox.create({ + data: { + id: event.transaction.inboxId, + isPublic: event.transaction.isPublic, + authPolicy: event.transaction.authPolicy, + encryptionPublicKey: event.transaction.encryptionPublicKey, + encryptedSecretKey: event.transaction.secretKey, + space: { connect: { id: spaceId } }, + spaceEvent: { connect: { id: event.transaction.id } }, + }, + }); + } }); } diff --git a/apps/server/src/handlers/createAccountInbox.ts b/apps/server/src/handlers/createAccountInbox.ts new file mode 100644 index 00000000..00088ccf --- /dev/null +++ b/apps/server/src/handlers/createAccountInbox.ts @@ -0,0 +1,18 @@ +import type { Messages } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; +export const createAccountInbox = async (data: Messages.RequestCreateAccountInbox) => { + const { accountId, inboxId, isPublic, authPolicy, encryptionPublicKey, signature } = data; + // This will throw an error if the inbox already exists + const inbox = await prisma.accountInbox.create({ + data: { + id: inboxId, + isPublic, + authPolicy, + encryptionPublicKey, + signatureHex: signature.hex, + signatureRecovery: signature.recovery, + account: { connect: { id: accountId } }, + }, + }); + return inbox; +}; diff --git a/apps/server/src/handlers/createAccountInboxMessage.ts b/apps/server/src/handlers/createAccountInboxMessage.ts new file mode 100644 index 00000000..4282d2c2 --- /dev/null +++ b/apps/server/src/handlers/createAccountInboxMessage.ts @@ -0,0 +1,48 @@ +import type { Messages } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; + +type Params = { + accountId: string; + inboxId: string; + message: Messages.RequestCreateAccountInboxMessage; +}; + +export const createAccountInboxMessage = async (params: Params): Promise => { + const { accountId, inboxId, message } = params; + const accountInbox = await prisma.accountInbox.findUnique({ + where: { + id: inboxId, + accountId, + }, + }); + if (!accountInbox) { + throw new Error('Account inbox not found'); + } + + const createdMessage = await prisma.accountInboxMessage.create({ + data: { + ciphertext: message.ciphertext, + signatureHex: message.signature?.hex ?? null, + signatureRecovery: message.signature?.recovery ?? null, + authorAccountId: message.authorAccountId ?? null, + accountInbox: { + connect: { + id: accountInbox.id, + }, + }, + }, + }); + return { + id: createdMessage.id, + ciphertext: createdMessage.ciphertext, + signature: + createdMessage.signatureHex != null && createdMessage.signatureRecovery != null + ? { + hex: createdMessage.signatureHex, + recovery: createdMessage.signatureRecovery, + } + : undefined, + authorAccountId: createdMessage.authorAccountId ?? undefined, + createdAt: createdMessage.createdAt, + }; +}; diff --git a/apps/server/src/handlers/createSpaceInboxMessage.ts b/apps/server/src/handlers/createSpaceInboxMessage.ts new file mode 100644 index 00000000..65628954 --- /dev/null +++ b/apps/server/src/handlers/createSpaceInboxMessage.ts @@ -0,0 +1,49 @@ +import type { Messages } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; + +type Params = { + spaceId: string; + inboxId: string; + message: Messages.RequestCreateSpaceInboxMessage; +}; + +export const createSpaceInboxMessage = async (params: Params): Promise => { + const { spaceId, inboxId, message } = params; + const spaceInbox = await prisma.spaceInbox.findUnique({ + where: { + id: inboxId, + }, + }); + if (!spaceInbox) { + throw new Error('Space inbox not found'); + } + if (spaceInbox.spaceId !== spaceId) { + throw new Error('Incorrect space'); + } + const createdMessage = await prisma.spaceInboxMessage.create({ + data: { + spaceInbox: { + connect: { + id: spaceInbox.id, + }, + }, + ciphertext: message.ciphertext, + signatureHex: message.signature?.hex ?? null, + signatureRecovery: message.signature?.recovery ?? null, + authorAccountId: message.authorAccountId ?? null, + }, + }); + return { + id: createdMessage.id, + ciphertext: createdMessage.ciphertext, + signature: + createdMessage.signatureHex != null && createdMessage.signatureRecovery != null + ? { + hex: createdMessage.signatureHex, + recovery: createdMessage.signatureRecovery, + } + : undefined, + authorAccountId: createdMessage.authorAccountId ?? undefined, + createdAt: createdMessage.createdAt, + }; +}; diff --git a/apps/server/src/handlers/getAccountInbox.ts b/apps/server/src/handlers/getAccountInbox.ts new file mode 100644 index 00000000..d327b916 --- /dev/null +++ b/apps/server/src/handlers/getAccountInbox.ts @@ -0,0 +1,36 @@ +import type { Inboxes } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; + +export async function getAccountInbox({ accountId, inboxId }: { accountId: string; inboxId: string }) { + const inbox = await prisma.accountInbox.findUnique({ + where: { id: inboxId, accountId }, + select: { + id: true, + account: { + select: { + id: true, + }, + }, + isPublic: true, + authPolicy: true, + encryptionPublicKey: true, + signatureHex: true, + signatureRecovery: true, + }, + }); + if (!inbox) { + throw new Error('Inbox not found'); + } + + return { + inboxId: inbox.id, + accountId: inbox.account.id, + isPublic: inbox.isPublic, + authPolicy: inbox.authPolicy as Inboxes.InboxSenderAuthPolicy, + encryptionPublicKey: inbox.encryptionPublicKey, + signature: { + hex: inbox.signatureHex, + recovery: inbox.signatureRecovery, + }, + }; +} diff --git a/apps/server/src/handlers/getIdentity.ts b/apps/server/src/handlers/getIdentity.ts index 242d3283..b2435ca2 100644 --- a/apps/server/src/handlers/getIdentity.ts +++ b/apps/server/src/handlers/getIdentity.ts @@ -10,7 +10,17 @@ type Params = signaturePublicKey: string; }; -export const getIdentity = async (params: Params) => { +export type GetIdentityResult = { + accountId: string; + ciphertext: string; + nonce: string; + signaturePublicKey: string; + encryptionPublicKey: string; + accountProof: string; + keyProof: string; +}; + +export const getIdentity = async (params: Params): Promise => { if (!params.accountId && !params.signaturePublicKey) { throw new Error('Either accountId or signaturePublicKey must be provided'); } @@ -18,7 +28,7 @@ export const getIdentity = async (params: Params) => { where: params, }); if (!identity) { - throw new Error(`Identity not found for account ${params.accountId}`); + throw new Error(`Identity not found for account ${params.accountId ?? params.signaturePublicKey}`); } return identity; }; diff --git a/apps/server/src/handlers/getLatestAccountInboxMessages.ts b/apps/server/src/handlers/getLatestAccountInboxMessages.ts new file mode 100644 index 00000000..cead2188 --- /dev/null +++ b/apps/server/src/handlers/getLatestAccountInboxMessages.ts @@ -0,0 +1,38 @@ +import type { Messages } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma.js'; + +interface GetLatestAccountInboxMessagesParams { + inboxId: string; + since: Date; +} + +export async function getLatestAccountInboxMessages({ + inboxId, + since, +}: GetLatestAccountInboxMessagesParams): Promise { + const messages = await prisma.accountInboxMessage.findMany({ + where: { + accountInboxId: inboxId, + createdAt: { + gte: since, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + return messages.map((msg) => ({ + id: msg.id, + ciphertext: msg.ciphertext, + signature: + msg.signatureHex != null && msg.signatureRecovery != null + ? { + hex: msg.signatureHex, + recovery: msg.signatureRecovery, + } + : undefined, + authorAccountId: msg.authorAccountId ?? undefined, + createdAt: msg.createdAt, + })); +} diff --git a/apps/server/src/handlers/getLatestSpaceInboxMessages.ts b/apps/server/src/handlers/getLatestSpaceInboxMessages.ts new file mode 100644 index 00000000..327563be --- /dev/null +++ b/apps/server/src/handlers/getLatestSpaceInboxMessages.ts @@ -0,0 +1,38 @@ +import type { Messages } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma.js'; + +interface GetLatestSpaceInboxMessagesParams { + inboxId: string; + since: Date; +} + +export async function getLatestSpaceInboxMessages({ + inboxId, + since, +}: GetLatestSpaceInboxMessagesParams): Promise { + const messages = await prisma.spaceInboxMessage.findMany({ + where: { + spaceInboxId: inboxId, + createdAt: { + gte: since, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + return messages.map((msg) => ({ + id: msg.id, + ciphertext: msg.ciphertext, + signature: + msg.signatureHex != null && msg.signatureRecovery != null + ? { + hex: msg.signatureHex, + recovery: msg.signatureRecovery, + } + : undefined, + authorAccountId: msg.authorAccountId ?? undefined, + createdAt: msg.createdAt, + })); +} diff --git a/apps/server/src/handlers/getSpace.ts b/apps/server/src/handlers/getSpace.ts index d570d3ce..1180a1d8 100644 --- a/apps/server/src/handlers/getSpace.ts +++ b/apps/server/src/handlers/getSpace.ts @@ -1,3 +1,4 @@ +import type { Inboxes } from '@graphprotocol/hypergraph'; import { prisma } from '../prisma.js'; type Params = { @@ -40,6 +41,15 @@ export const getSpace = async ({ spaceId, accountId }: Params) => { clock: 'asc', }, }, + inboxes: { + select: { + id: true, + isPublic: true, + authPolicy: true, + encryptionPublicKey: true, + encryptedSecretKey: true, + }, + }, }, }); @@ -53,26 +63,29 @@ export const getSpace = async ({ spaceId, accountId }: Params) => { }; }); - const formatUpdate = (update) => { - return { - accountId: update.accountId, - update: new Uint8Array(update.content), - signature: { - hex: update.signatureHex, - recovery: update.signatureRecovery, - }, - updateId: update.updateId, - }; - }; - return { id: space.id, events: space.events.map((wrapper) => JSON.parse(wrapper.event)), keyBoxes, + inboxes: space.inboxes.map((inbox) => ({ + inboxId: inbox.id, + isPublic: inbox.isPublic, + authPolicy: inbox.authPolicy as Inboxes.InboxSenderAuthPolicy, + encryptionPublicKey: inbox.encryptionPublicKey, + secretKey: inbox.encryptedSecretKey, + })), updates: space.updates.length > 0 ? { - updates: space.updates.map(formatUpdate), + updates: space.updates.map((update) => ({ + accountId: update.accountId, + update: new Uint8Array(update.content), + signature: { + hex: update.signatureHex, + recovery: update.signatureRecovery, + }, + updateId: update.updateId, + })), firstUpdateClock: space.updates[0].clock, lastUpdateClock: space.updates[space.updates.length - 1].clock, } diff --git a/apps/server/src/handlers/getSpaceInbox.ts b/apps/server/src/handlers/getSpaceInbox.ts new file mode 100644 index 00000000..715ef294 --- /dev/null +++ b/apps/server/src/handlers/getSpaceInbox.ts @@ -0,0 +1,30 @@ +import type { Inboxes, SpaceEvents } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; + +export async function getSpaceInbox({ spaceId, inboxId }: { spaceId: string; inboxId: string }) { + const inbox = await prisma.spaceInbox.findUnique({ + where: { id: inboxId, spaceId }, + select: { + id: true, + isPublic: true, + authPolicy: true, + encryptionPublicKey: true, + spaceEvent: { + select: { + event: true, + }, + }, + }, + }); + if (!inbox) { + throw new Error('Inbox not found'); + } + + return { + inboxId: inbox.id, + isPublic: inbox.isPublic, + authPolicy: inbox.authPolicy as Inboxes.InboxSenderAuthPolicy, + encryptionPublicKey: inbox.encryptionPublicKey, + creationEvent: JSON.parse(inbox.spaceEvent.event) as SpaceEvents.CreateSpaceInboxEvent, + }; +} diff --git a/apps/server/src/handlers/listAccountInboxes.ts b/apps/server/src/handlers/listAccountInboxes.ts new file mode 100644 index 00000000..6d99de73 --- /dev/null +++ b/apps/server/src/handlers/listAccountInboxes.ts @@ -0,0 +1,34 @@ +import type { Inboxes } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; + +export async function listAccountInboxes({ accountId }: { accountId: string }) { + const inboxes = await prisma.accountInbox.findMany({ + where: { accountId }, + select: { + id: true, + isPublic: true, + authPolicy: true, + encryptionPublicKey: true, + account: { + select: { + id: true, + }, + }, + signatureHex: true, + signatureRecovery: true, + }, + }); + return inboxes.map((inbox) => { + return { + inboxId: inbox.id, + accountId: inbox.account.id, + isPublic: inbox.isPublic, + authPolicy: inbox.authPolicy as Inboxes.InboxSenderAuthPolicy, + encryptionPublicKey: inbox.encryptionPublicKey, + signature: { + hex: inbox.signatureHex, + recovery: inbox.signatureRecovery, + }, + }; + }); +} diff --git a/apps/server/src/handlers/listPublicAccountInboxes.ts b/apps/server/src/handlers/listPublicAccountInboxes.ts new file mode 100644 index 00000000..4d9eacd4 --- /dev/null +++ b/apps/server/src/handlers/listPublicAccountInboxes.ts @@ -0,0 +1,34 @@ +import type { Inboxes } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; + +export async function listPublicAccountInboxes({ accountId }: { accountId: string }) { + const inboxes = await prisma.accountInbox.findMany({ + where: { accountId, isPublic: true }, + select: { + id: true, + isPublic: true, + authPolicy: true, + encryptionPublicKey: true, + account: { + select: { + id: true, + }, + }, + signatureHex: true, + signatureRecovery: true, + }, + }); + return inboxes.map((inbox) => { + return { + inboxId: inbox.id, + accountId: inbox.account.id, + isPublic: inbox.isPublic, + authPolicy: inbox.authPolicy as Inboxes.InboxSenderAuthPolicy, + encryptionPublicKey: inbox.encryptionPublicKey, + signature: { + hex: inbox.signatureHex, + recovery: inbox.signatureRecovery, + }, + }; + }); +} diff --git a/apps/server/src/handlers/listPublicSpaceInboxes.ts b/apps/server/src/handlers/listPublicSpaceInboxes.ts new file mode 100644 index 00000000..7b23488b --- /dev/null +++ b/apps/server/src/handlers/listPublicSpaceInboxes.ts @@ -0,0 +1,28 @@ +import type { Inboxes, SpaceEvents } from '@graphprotocol/hypergraph'; +import { prisma } from '../prisma'; + +export async function listPublicSpaceInboxes({ spaceId }: { spaceId: string }) { + const inboxes = await prisma.spaceInbox.findMany({ + where: { spaceId, isPublic: true }, + select: { + id: true, + isPublic: true, + authPolicy: true, + encryptionPublicKey: true, + spaceEvent: { + select: { + event: true, + }, + }, + }, + }); + return inboxes.map((inbox) => { + return { + inboxId: inbox.id, + isPublic: inbox.isPublic, + authPolicy: inbox.authPolicy as Inboxes.InboxSenderAuthPolicy, + encryptionPublicKey: inbox.encryptionPublicKey, + creationEvent: JSON.parse(inbox.spaceEvent.event) as SpaceEvents.CreateSpaceInboxEvent, + }; + }); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 269ab081..8718e79d 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,18 +1,28 @@ -import { Identity, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph'; +import { parse } from 'node:url'; +import { Identity, Inboxes, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph'; import cors from 'cors'; import { Effect, Exit, Schema } from 'effect'; import express, { type Request, type Response } from 'express'; -import { parse } from 'node:url'; import { SiweMessage } from 'siwe'; import type { Hex } from 'viem'; import WebSocket, { WebSocketServer } from 'ws'; import { applySpaceEvent } from './handlers/applySpaceEvent.js'; +import { createAccountInbox } from './handlers/createAccountInbox.js'; +import { createAccountInboxMessage } from './handlers/createAccountInboxMessage.js'; import { createIdentity } from './handlers/createIdentity.js'; import { createSpace } from './handlers/createSpace.js'; +import { createSpaceInboxMessage } from './handlers/createSpaceInboxMessage.js'; import { createUpdate } from './handlers/createUpdate.js'; -import { getIdentity } from './handlers/getIdentity.js'; +import { getAccountInbox } from './handlers/getAccountInbox.js'; +import { type GetIdentityResult, getIdentity } from './handlers/getIdentity.js'; +import { getLatestAccountInboxMessages } from './handlers/getLatestAccountInboxMessages.js'; +import { getLatestSpaceInboxMessages } from './handlers/getLatestSpaceInboxMessages.js'; import { getSpace } from './handlers/getSpace.js'; +import { getSpaceInbox } from './handlers/getSpaceInbox.js'; +import { listAccountInboxes } from './handlers/listAccountInboxes.js'; import { listInvitations } from './handlers/listInvitations.js'; +import { listPublicAccountInboxes } from './handlers/listPublicAccountInboxes.js'; +import { listPublicSpaceInboxes } from './handlers/listPublicSpaceInboxes.js'; import { listSpaces } from './handlers/listSpaces.js'; import { createSessionNonce, getSessionNonce } from './handlers/sessionNonce.js'; import { createSessionToken, getAccountIdBySessionToken } from './handlers/sessionToken.js'; @@ -271,6 +281,167 @@ app.get('/identity', async (req, res) => { } }); +app.get('/spaces/:spaceId/inboxes', async (req, res) => { + console.log('GET spaces/:spaceId/inboxes'); + const spaceId = req.params.spaceId; + const inboxes = await listPublicSpaceInboxes({ spaceId }); + const outgoingMessage: Messages.ResponseListSpaceInboxesPublic = { + inboxes, + }; + res.status(200).send(outgoingMessage); +}); + +app.get('/spaces/:spaceId/inboxes/:inboxId', async (req, res) => { + console.log('GET spaces/:spaceId/inboxes/:inboxId'); + const spaceId = req.params.spaceId; + const inboxId = req.params.inboxId; + const inbox = await getSpaceInbox({ spaceId, inboxId }); + const outgoingMessage: Messages.ResponseSpaceInboxPublic = { + inbox, + }; + res.status(200).send(outgoingMessage); +}); + +app.post('/spaces/:spaceId/inboxes/:inboxId/messages', async (req, res) => { + console.log('POST spaces/:spaceId/inboxes/:inboxId/messages'); + const spaceId = req.params.spaceId; + const inboxId = req.params.inboxId; + const message = Schema.decodeUnknownSync(Messages.RequestCreateSpaceInboxMessage)(req.body); + let spaceInbox: Messages.SpaceInboxPublic; + try { + spaceInbox = await getSpaceInbox({ spaceId, inboxId }); + } catch (error) { + res.status(404).send({ error: 'Inbox not found' }); + return; + } + + switch (spaceInbox.authPolicy) { + case 'requires_auth': + if (!message.signature || !message.authorAccountId) { + res.status(400).send({ error: 'Signature and authorAccountId required' }); + return; + } + break; + case 'anonymous': + if (message.signature || message.authorAccountId) { + res.status(400).send({ error: 'Signature and authorAccountId not allowed' }); + return; + } + break; + case 'optional_auth': + if ((message.signature && !message.authorAccountId) || (!message.signature && message.authorAccountId)) { + res.status(400).send({ error: 'Signature and authorAccountId must be provided together' }); + return; + } + break; + default: + // This shouldn't happen + res.status(500).send({ error: 'Unknown auth policy' }); + return; + } + + if (message.signature && message.authorAccountId) { + // Recover the public key from the signature + const authorPublicKey = Inboxes.recoverSpaceInboxMessageSigner(message, spaceId, inboxId); + + // Check if this public key corresponds to a user's identity + let authorIdentity: GetIdentityResult; + try { + authorIdentity = await getIdentity({ signaturePublicKey: authorPublicKey }); + } catch (error) { + res.status(403).send({ error: 'Not authorized to post to this inbox' }); + return; + } + if (authorIdentity.accountId !== message.authorAccountId) { + res.status(403).send({ error: 'Not authorized to post to this inbox' }); + return; + } + } + const createdMessage = await createSpaceInboxMessage({ spaceId, inboxId, message }); + res.status(200).send({}); + broadcastSpaceInboxMessage({ spaceId, inboxId, message: createdMessage }); +}); + +app.get('/accounts/:accountId/inboxes', async (req, res) => { + console.log('GET accounts/:accountId/inboxes'); + const accountId = req.params.accountId; + const inboxes = await listPublicAccountInboxes({ accountId }); + const outgoingMessage: Messages.ResponseListAccountInboxesPublic = { + inboxes, + }; + res.status(200).send(outgoingMessage); +}); + +app.get('/accounts/:accountId/inboxes/:inboxId', async (req, res) => { + console.log('GET accounts/:accountId/inboxes/:inboxId'); + const accountId = req.params.accountId; + const inboxId = req.params.inboxId; + const inbox = await getAccountInbox({ accountId, inboxId }); + const outgoingMessage: Messages.ResponseAccountInboxPublic = { + inbox, + }; + res.status(200).send(outgoingMessage); +}); + +app.post('/accounts/:accountId/inboxes/:inboxId/messages', async (req, res) => { + console.log('POST accounts/:accountId/inboxes/:inboxId/messages'); + const accountId = req.params.accountId; + const inboxId = req.params.inboxId; + const message = Schema.decodeUnknownSync(Messages.RequestCreateAccountInboxMessage)(req.body); + let accountInbox: Messages.AccountInboxPublic; + try { + accountInbox = await getAccountInbox({ accountId, inboxId }); + } catch (error) { + res.status(404).send({ error: 'Inbox not found' }); + return; + } + + switch (accountInbox.authPolicy) { + case 'requires_auth': + if (!message.signature || !message.authorAccountId) { + res.status(400).send({ error: 'Signature and authorAccountId required' }); + return; + } + break; + case 'anonymous': + if (message.signature || message.authorAccountId) { + res.status(400).send({ error: 'Signature and authorAccountId not allowed' }); + return; + } + break; + case 'optional_auth': + if ((message.signature && !message.authorAccountId) || (!message.signature && message.authorAccountId)) { + res.status(400).send({ error: 'Signature and authorAccountId must be provided together' }); + return; + } + break; + default: + // This shouldn't happen + res.status(500).send({ error: 'Unknown auth policy' }); + return; + } + if (message.signature && message.authorAccountId) { + // Recover the public key from the signature + const authorPublicKey = Inboxes.recoverAccountInboxMessageSigner(message, accountId, inboxId); + + // Check if this public key corresponds to a user's identity + let authorIdentity: GetIdentityResult; + try { + authorIdentity = await getIdentity({ signaturePublicKey: authorPublicKey }); + } catch (error) { + res.status(403).send({ error: 'Not authorized to post to this inbox' }); + return; + } + if (authorIdentity.accountId !== message.authorAccountId) { + res.status(403).send({ error: 'Not authorized to post to this inbox' }); + return; + } + } + const createdMessage = await createAccountInboxMessage({ accountId, inboxId, message }); + res.status(200).send({}); + broadcastAccountInboxMessage({ accountId, inboxId, message: createdMessage }); +}); + const server = app.listen(PORT, () => { console.log(`Listening on port ${PORT}`); }); @@ -313,6 +484,54 @@ function broadcastUpdates({ } } +function broadcastSpaceInboxMessage({ + spaceId, + inboxId, + message, +}: { spaceId: string; inboxId: string; message: Messages.InboxMessage }) { + const outgoingMessage: Messages.ResponseSpaceInboxMessage = { + type: 'space-inbox-message', + spaceId, + inboxId, + message, + }; + for (const client of webSocketServer.clients as Set) { + if (client.readyState === WebSocket.OPEN && client.subscribedSpaces.has(spaceId)) { + client.send(Messages.serialize(outgoingMessage)); + } + } +} + +function broadcastAccountInbox({ inbox }: { inbox: Messages.AccountInboxPublic }) { + const outgoingMessage: Messages.ResponseAccountInbox = { + type: 'account-inbox', + inbox, + }; + for (const client of webSocketServer.clients as Set) { + if (client.readyState === WebSocket.OPEN && client.accountId === inbox.accountId) { + client.send(Messages.serialize(outgoingMessage)); + } + } +} + +function broadcastAccountInboxMessage({ + accountId, + inboxId, + message, +}: { accountId: string; inboxId: string; message: Messages.InboxMessage }) { + const outgoingMessage: Messages.ResponseAccountInboxMessage = { + type: 'account-inbox-message', + accountId, + inboxId, + message, + }; + for (const client of webSocketServer.clients as Set) { + if (client.readyState === WebSocket.OPEN && client.accountId === accountId) { + client.send(Messages.serialize(outgoingMessage)); + } + } +} + webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Request) => { console.log('WS connection'); const params = parse(request.url, true); @@ -417,10 +636,7 @@ webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Req }; webSocket.send(Messages.serialize(outgoingMessage)); for (const client of webSocketServer.clients as Set) { - if ( - client.readyState === WebSocket.OPEN && - client.accountId === data.event.transaction.inviteeAccountId - ) { + if (client.readyState === WebSocket.OPEN && client.accountId === data.event.transaction.inviteeAccountId) { const invitations = await listInvitations({ accountId: client.accountId }); const outgoingMessage: Messages.ResponseListInvitations = { type: 'list-invitations', @@ -445,6 +661,90 @@ webSocketServer.on('connection', async (webSocket: CustomWebSocket, request: Req broadcastSpaceEvents({ spaceId: data.spaceId, event: data.event, currentClient: webSocket }); break; } + case 'create-space-inbox-event': { + await applySpaceEvent({ accountId, spaceId: data.spaceId, event: data.event, keyBoxes: [] }); + const spaceWithEvents = await getSpace({ accountId, spaceId: data.spaceId }); + // TODO send back confirmation instead of the entire space + const outgoingMessage: Messages.ResponseSpace = { + ...spaceWithEvents, + type: 'space', + }; + webSocket.send(Messages.serialize(outgoingMessage)); + broadcastSpaceEvents({ spaceId: data.spaceId, event: data.event, currentClient: webSocket }); + break; + } + case 'create-account-inbox': { + try { + // Check that the signature is valid for the corresponding accountId + if (data.accountId !== accountId) { + throw new Error('Invalid accountId'); + } + const signer = Inboxes.recoverAccountInboxCreatorKey(data); + const signerAccount = await getIdentity({ signaturePublicKey: signer }); + if (signerAccount.accountId !== accountId) { + throw new Error('Invalid signature'); + } + // Create the inbox (if it doesn't exist) + await createAccountInbox(data); + // Broadcast the inbox to other clients from the same account + broadcastAccountInbox({ inbox: data }); + } catch (error) { + console.error('Error creating account inbox:', error); + return; + } + break; + } + case 'get-latest-space-inbox-messages': { + try { + // Check that the user has access to this space + await getSpace({ accountId, spaceId: data.spaceId }); + const messages = await getLatestSpaceInboxMessages({ + inboxId: data.inboxId, + since: data.since, + }); + const outgoingMessage: Messages.ResponseSpaceInboxMessages = { + type: 'space-inbox-messages', + spaceId: data.spaceId, + inboxId: data.inboxId, + messages, + }; + webSocket.send(Messages.serialize(outgoingMessage)); + } catch (error) { + console.error('Error getting latest space inbox messages:', error); + return; + } + break; + } + case 'get-latest-account-inbox-messages': { + try { + // Check that the user has access to this inbox + await getAccountInbox({ accountId, inboxId: data.inboxId }); + const messages = await getLatestAccountInboxMessages({ + inboxId: data.inboxId, + since: data.since, + }); + const outgoingMessage: Messages.ResponseAccountInboxMessages = { + type: 'account-inbox-messages', + accountId, + inboxId: data.inboxId, + messages, + }; + webSocket.send(Messages.serialize(outgoingMessage)); + } catch (error) { + console.error('Error getting latest account inbox messages:', error); + return; + } + break; + } + case 'get-account-inboxes': { + const inboxes = await listAccountInboxes({ accountId }); + const outgoingMessage: Messages.ResponseAccountInboxes = { + type: 'account-inboxes', + inboxes, + }; + webSocket.send(Messages.serialize(outgoingMessage)); + break; + } case 'create-update': { try { // Check that the update was signed by a valid identity diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index 0c3355b2..a2e3ef3e 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -4,7 +4,17 @@ import * as automerge from '@automerge/automerge'; import { uuid } from '@automerge/automerge'; import type { DocHandle } from '@automerge/automerge-repo'; import { RepoContext } from '@automerge/automerge-repo-react-hooks'; -import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, store } from '@graphprotocol/hypergraph'; +import { + Identity, + type InboxMessageStorageEntry, + Inboxes, + Key, + Messages, + SpaceEvents, + type SpaceStorageEntry, + Utils, + store, +} from '@graphprotocol/hypergraph'; import { useSelector as useSelectorStore } from '@xstate/store/react'; import { Effect, Exit } from 'effect'; import * as Schema from 'effect/Schema'; @@ -30,6 +40,39 @@ export type HypergraphAppCtx = { // app related invitations: Array; createSpace(): Promise; + createSpaceInbox( + params: Readonly<{ space: SpaceStorageEntry; isPublic: boolean; authPolicy: Inboxes.InboxSenderAuthPolicy }>, + ): Promise; + getLatestSpaceInboxMessages(params: Readonly<{ spaceId: string; inboxId: string }>): Promise; + listPublicSpaceInboxes(params: Readonly<{ spaceId: string }>): Promise; + getSpaceInbox(params: Readonly<{ spaceId: string; inboxId: string }>): Promise; + sendSpaceInboxMessage( + params: Readonly<{ + message: string; + spaceId: string; + inboxId: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string | null; + }>, + ): Promise; + createAccountInbox( + params: Readonly<{ isPublic: boolean; authPolicy: Inboxes.InboxSenderAuthPolicy }>, + ): Promise; + getLatestAccountInboxMessages(params: Readonly<{ accountId: string; inboxId: string }>): Promise; + getOwnAccountInboxes(): Promise; + listPublicAccountInboxes(params: Readonly<{ accountId: string }>): Promise; + getAccountInbox(params: Readonly<{ accountId: string; inboxId: string }>): Promise; + sendAccountInboxMessage( + params: Readonly<{ + message: string; + accountId: string; + inboxId: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string | null; + }>, + ): Promise; listSpaces(): void; listInvitations(): void; acceptInvitation(params: Readonly<{ invitation: Messages.Invitation }>): Promise; @@ -41,6 +84,12 @@ export type HypergraphAppCtx = { signaturePublicKey: string; }>; loading: boolean; + ensureSpaceInbox(params: { + spaceId: string; + isPublic?: boolean; + authPolicy?: Inboxes.InboxSenderAuthPolicy; + index?: number; + }): Promise; }; export const HypergraphAppContext = createContext({ @@ -57,6 +106,39 @@ export const HypergraphAppContext = createContext({ async createSpace() { throw new Error('createSpace is missing'); }, + async createSpaceInbox() { + throw new Error('createSpaceInbox is missing'); + }, + async getLatestSpaceInboxMessages() { + throw new Error('getLatestSpaceInboxMessages is missing'); + }, + async listPublicSpaceInboxes() { + throw new Error('listPublicSpaceInboxes is missing'); + }, + async getSpaceInbox() { + throw new Error('getSpaceInbox is missing'); + }, + async sendSpaceInboxMessage() { + throw new Error('sendSpaceInboxMessage is missing'); + }, + async createAccountInbox() { + throw new Error('createAccountInbox is missing'); + }, + async getOwnAccountInboxes() { + throw new Error('getOwnAccountInboxes is missing'); + }, + async getLatestAccountInboxMessages() { + throw new Error('getLatestAccountInboxMessages is missing'); + }, + async listPublicAccountInboxes() { + throw new Error('listPublicAccountInboxes is missing'); + }, + async getAccountInbox() { + throw new Error('getAccountInbox is missing'); + }, + async sendAccountInboxMessage() { + throw new Error('sendAccountInboxMessage is missing'); + }, listSpaces() { throw new Error('listSpaces is missing'); }, @@ -76,6 +158,9 @@ export const HypergraphAppContext = createContext({ throw new Error('getVerifiedIdentity is missing'); }, loading: true, + async ensureSpaceInbox() { + throw new Error('ensureSpaceInbox is missing'); + }, }); export function useHypergraphApp() { @@ -259,6 +344,11 @@ export function HypergraphAppProvider({ console.error('No encryption private key found'); return; } + const encryptionPublicKey = keys?.encryptionPublicKey; + if (!encryptionPublicKey) { + console.error('No encryption public key found'); + return; + } const signaturePrivateKey = keys?.signaturePrivateKey; if (!signaturePrivateKey) { console.error('No signature private key found.'); @@ -364,11 +454,30 @@ export function HypergraphAppProvider({ return { id: keyBox.id, key: Utils.bytesToHex(key) }; }); + const inboxes = response.inboxes.map((inbox) => { + return { + inboxId: inbox.inboxId, + isPublic: inbox.isPublic, + authPolicy: inbox.authPolicy, + encryptionPublicKey: inbox.encryptionPublicKey, + secretKey: Utils.bytesToHex( + Messages.decryptMessage({ + nonceAndCiphertext: Utils.hexToBytes(inbox.secretKey), + secretKey: Utils.hexToBytes(keys[0].key), + }), + ), + messages: [], + lastMessageClock: new Date(0).toISOString(), + seenMessageIds: new Set(), + }; + }); + store.send({ type: 'setSpace', spaceId: response.id, updates: response.updates as Messages.Updates, events: response.events as Array, + inboxes, spaceState: newState, keys, }); @@ -434,6 +543,28 @@ export function HypergraphAppProvider({ state: applyEventResult.value, }); } + if (response.event.transaction.type === 'create-space-inbox') { + const inbox = { + inboxId: response.event.transaction.id, + isPublic: response.event.transaction.isPublic, + authPolicy: response.event.transaction.authPolicy, + encryptionPublicKey: response.event.transaction.encryptionPublicKey, + secretKey: Utils.bytesToHex( + Messages.decryptMessage({ + nonceAndCiphertext: Utils.hexToBytes(response.event.transaction.secretKey), + secretKey: Utils.hexToBytes(space.keys[0].key), + }), + ), + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + store.send({ + type: 'setSpaceInbox', + spaceId: response.spaceId, + inbox, + }); + } break; } @@ -472,6 +603,258 @@ export function HypergraphAppProvider({ await applyUpdates(response.spaceId, space.keys[0].key, space.automergeDocHandle, response.updates); break; } + case 'account-inbox': { + // Validate the signature of the inbox corresponds to the current account's identity + if (!keys.signaturePrivateKey) { + console.error('No signature private key found to process account inbox'); + return; + } + const inboxCreator = Inboxes.recoverAccountInboxCreatorKey(response.inbox); + if (inboxCreator !== keys.signaturePublicKey) { + console.error('Invalid inbox creator', response.inbox); + return; + } + + const messages: InboxMessageStorageEntry[] = []; + + store.send({ + type: 'setAccountInbox', + inbox: { + ...response.inbox, + messages, + lastMessageClock: new Date(0).toISOString(), + seenMessageIds: new Set(), + }, + }); + break; + } + case 'space-inbox-message': { + const inbox = store + .getSnapshot() + .context.spaces.find((s) => s.id === response.spaceId) + ?.inboxes.find((i) => i.inboxId === response.inboxId); + if (!inbox) { + console.error('Inbox not found', response.inboxId); + return; + } + const isValid = await Inboxes.validateSpaceInboxMessage( + response.message, + inbox, + response.spaceId, + syncServerUri, + ); + if (!isValid) { + console.error('Invalid message', response.message, inbox.inboxId); + return; + } + try { + const decryptedMessage = Inboxes.decryptInboxMessage({ + ciphertext: response.message.ciphertext, + encryptionPrivateKey: inbox.secretKey, + encryptionPublicKey: inbox.encryptionPublicKey, + }); + const message = { + ...response.message, + createdAt: response.message.createdAt.toISOString(), + plaintext: decryptedMessage, + signature: response.message.signature + ? { + hex: response.message.signature.hex, + recovery: response.message.signature.recovery, + } + : null, + authorAccountId: response.message.authorAccountId ?? null, + }; + store.send({ + type: 'setSpaceInboxMessages', + spaceId: response.spaceId, + inboxId: response.inboxId, + messages: [message], + lastMessageClock: message.createdAt, + }); + } catch (error) { + console.error('Error decrypting message', error); + } + break; + } + case 'account-inbox-message': { + const inbox = store.getSnapshot().context.accountInboxes.find((i) => i.inboxId === response.inboxId); + if (!inbox) { + console.error('Inbox not found', response.inboxId); + return; + } + const isValid = await Inboxes.validateAccountInboxMessage( + response.message, + inbox, + accountId, + syncServerUri, + ); + if (!isValid) { + console.error('Invalid message', response.message, inbox.inboxId); + return; + } + try { + const decryptedMessage = Inboxes.decryptInboxMessage({ + ciphertext: response.message.ciphertext, + encryptionPrivateKey: encryptionPrivateKey, + encryptionPublicKey: encryptionPublicKey, + }); + const message = { + ...response.message, + createdAt: response.message.createdAt.toISOString(), + plaintext: decryptedMessage, + signature: response.message.signature + ? { + hex: response.message.signature.hex, + recovery: response.message.signature.recovery, + } + : null, + authorAccountId: response.message.authorAccountId ?? null, + }; + store.send({ + type: 'setAccountInboxMessages', + inboxId: response.inboxId, + messages: [message], + lastMessageClock: message.createdAt, + }); + } catch (error) { + console.error('Error decrypting message', error); + } + break; + } + case 'account-inboxes': { + response.inboxes.map((inbox) => { + store.send({ + type: 'setAccountInbox', + inbox: { + ...inbox, + messages: [], + lastMessageClock: new Date(0).toISOString(), + seenMessageIds: new Set(), + }, + }); + }); + break; + } + case 'account-inbox-messages': { + // Validate the signature of the inbox corresponds to the current account's identity + if (!keys.signaturePrivateKey) { + console.error('No signature private key found to process account inbox'); + return; + } + const inbox = store.getSnapshot().context.accountInboxes.find((i) => i.inboxId === response.inboxId); + if (!inbox) { + console.error('Inbox not found', response.inboxId); + return; + } + const validSignatures = await Promise.all( + response.messages.map( + // If the message has a signature, check that the signature is valid for the authorAccountId + async (message) => { + return Inboxes.validateAccountInboxMessage(message, inbox, accountId, syncServerUri); + }, + ), + ); + let lastMessageClock = new Date(0); + const messages = response.messages + .filter((message, index) => validSignatures[index]) + .map((message) => { + try { + const decryptedMessage = Inboxes.decryptInboxMessage({ + ciphertext: message.ciphertext, + encryptionPrivateKey, + encryptionPublicKey, + }); + if (message.createdAt > lastMessageClock) { + lastMessageClock = message.createdAt; + } + return { + ...message, + createdAt: message.createdAt.toISOString(), + plaintext: decryptedMessage, + signature: message.signature + ? { + hex: message.signature.hex, + recovery: message.signature.recovery, + } + : null, + authorAccountId: message.authorAccountId ?? null, + }; + } catch (error) { + console.error('Error decrypting message', error); + return null; + } + }) + .filter((message) => message !== null); + + store.send({ + type: 'setAccountInboxMessages', + inboxId: response.inboxId, + messages, + lastMessageClock: lastMessageClock.toISOString(), + }); + break; + } + case 'space-inbox-messages': { + const space = store.getSnapshot().context.spaces.find((s) => s.id === response.spaceId); + if (!space) { + console.error('Space not found', response.spaceId); + return; + } + const inbox = space.inboxes.find((i) => i.inboxId === response.inboxId); + if (!inbox) { + console.error('Inbox not found', response.inboxId); + return; + } + let lastMessageClock = new Date(0); + const validSignatures = await Promise.all( + response.messages.map( + // If the message has a signature, check that the signature is valid for the authorAccountId + async (message) => { + return Inboxes.validateSpaceInboxMessage(message, inbox, space.id, syncServerUri); + }, + ), + ); + const messages = response.messages + .filter((message, index) => validSignatures[index]) + .map((message) => { + try { + const decryptedMessage = Inboxes.decryptInboxMessage({ + ciphertext: message.ciphertext, + encryptionPrivateKey: inbox.secretKey, + encryptionPublicKey: inbox.encryptionPublicKey, + }); + if (message.createdAt > lastMessageClock) { + lastMessageClock = message.createdAt; + } + return { + ...message, + createdAt: message.createdAt.toISOString(), + signature: message.signature + ? { + hex: message.signature.hex, + recovery: message.signature.recovery, + } + : null, + authorAccountId: message.authorAccountId ?? null, + plaintext: decryptedMessage, + }; + } catch (error) { + console.error('Error decrypting message', error); + return null; + } + }) + .filter((message) => message !== null); + + store.send({ + type: 'setSpaceInboxMessages', + spaceId: response.spaceId, + inboxId: response.inboxId, + messages, + lastMessageClock: lastMessageClock.toISOString(), + }); + break; + } default: { Utils.assertExhaustive(response); } @@ -484,7 +867,16 @@ export function HypergraphAppProvider({ return () => { websocketConnection.removeEventListener('message', onMessage); }; - }, [websocketConnection, spaces, accountId, keys?.encryptionPrivateKey, keys?.signaturePrivateKey, syncServerUri]); + }, [ + websocketConnection, + spaces, + accountId, + keys?.encryptionPrivateKey, + keys?.encryptionPublicKey, + keys?.signaturePrivateKey, + keys?.signaturePublicKey, + syncServerUri, + ]); const createSpaceForContext = useCallback<() => Promise>(async () => { if (!accountId) { @@ -548,6 +940,218 @@ export function HypergraphAppProvider({ websocketConnection?.send(Messages.serialize(message)); }, [websocketConnection]); + const createSpaceInboxForContext = useCallback( + async ({ + space, + isPublic, + authPolicy, + }: Readonly<{ space: SpaceStorageEntry; isPublic: boolean; authPolicy: Inboxes.InboxSenderAuthPolicy }>) => { + if (!accountId) { + throw new Error('No account id found'); + } + const encryptionPrivateKey = keys?.encryptionPrivateKey; + const encryptionPublicKey = keys?.encryptionPublicKey; + const signaturePrivateKey = keys?.signaturePrivateKey; + const signaturePublicKey = keys?.signaturePublicKey; + if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { + throw new Error('Missing keys'); + } + if (!space.state) { + console.error('Space has no state', space.id); + return; + } + const message = await Inboxes.createSpaceInboxCreationMessage({ + author: { + accountId, + signaturePublicKey, + encryptionPublicKey, + signaturePrivateKey, + }, + spaceId: space.id, + isPublic, + authPolicy, + spaceSecretKey: space.keys[0].key, + previousEventHash: space.state.lastEventHash, + }); + websocketConnection?.send(Messages.serialize(message)); + }, + [ + accountId, + keys?.encryptionPrivateKey, + keys?.encryptionPublicKey, + keys?.signaturePrivateKey, + keys?.signaturePublicKey, + websocketConnection, + ], + ); + + const getLatestSpaceInboxMessagesForContext = useCallback( + async ({ spaceId, inboxId }: Readonly<{ spaceId: string; inboxId: string }>) => { + const storeState = store.getSnapshot(); + const space = storeState.context.spaces.find((s) => s.id === spaceId); + if (!space) { + console.error('Space not found', spaceId); + return; + } + const inbox = space.inboxes.find((i) => i.inboxId === inboxId); + if (!inbox) { + console.error('Inbox not found', inboxId); + return; + } + const latestMessageClock = inbox.lastMessageClock; + const message: Messages.RequestGetLatestSpaceInboxMessages = { + type: 'get-latest-space-inbox-messages', + spaceId, + inboxId, + since: new Date(latestMessageClock), + }; + websocketConnection?.send(Messages.serialize(message)); + }, + [websocketConnection], + ); + + const listPublicSpaceInboxesForContext = useCallback( + async ({ spaceId }: Readonly<{ spaceId: string }>): Promise => { + return await Inboxes.listPublicSpaceInboxes({ spaceId, syncServerUri }); + }, + [syncServerUri], + ); + + const getSpaceInboxForContext = useCallback( + async ({ + spaceId, + inboxId, + }: Readonly<{ spaceId: string; inboxId: string }>): Promise => { + return await Inboxes.getSpaceInbox({ spaceId, inboxId, syncServerUri }); + }, + [syncServerUri], + ); + + const sendSpaceInboxMessageForContext = useCallback( + async ({ + spaceId, + inboxId, + message, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }: Readonly<{ + spaceId: string; + inboxId: string; + message: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string; + }>) => { + return await Inboxes.sendSpaceInboxMessage({ + spaceId, + inboxId, + message, + encryptionPublicKey, + signaturePrivateKey, + syncServerUri, + authorAccountId, + }); + }, + [syncServerUri], + ); + + const createAccountInboxForContext = useCallback( + async ({ isPublic, authPolicy }: Readonly<{ isPublic: boolean; authPolicy: Inboxes.InboxSenderAuthPolicy }>) => { + if (!accountId) { + throw new Error('No account id found'); + } + const encryptionPrivateKey = keys?.encryptionPrivateKey; + const encryptionPublicKey = keys?.encryptionPublicKey; + const signaturePrivateKey = keys?.signaturePrivateKey; + if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey) { + throw new Error('Missing keys'); + } + const message = await Inboxes.createAccountInboxCreationMessage({ + accountId, + isPublic, + authPolicy, + encryptionPublicKey, + signaturePrivateKey, + }); + websocketConnection?.send(Messages.serialize(message)); + }, + [accountId, keys?.encryptionPrivateKey, keys?.encryptionPublicKey, keys?.signaturePrivateKey, websocketConnection], + ); + + const getLatestAccountInboxMessagesForContext = useCallback( + async ({ accountId, inboxId }: Readonly<{ accountId: string; inboxId: string }>) => { + const storeState = store.getSnapshot(); + const inbox = storeState.context.accountInboxes.find((i) => i.inboxId === inboxId); + if (!inbox) { + console.error('Inbox not found', inboxId); + return; + } + const latestMessageClock = inbox.lastMessageClock; + const message: Messages.RequestGetLatestAccountInboxMessages = { + type: 'get-latest-account-inbox-messages', + accountId, + inboxId, + since: new Date(latestMessageClock), + }; + websocketConnection?.send(Messages.serialize(message)); + }, + [websocketConnection], + ); + + const getOwnAccountInboxesForContext = useCallback(async () => { + const message: Messages.RequestGetAccountInboxes = { + type: 'get-account-inboxes', + }; + websocketConnection?.send(Messages.serialize(message)); + }, [websocketConnection]); + + const listPublicAccountInboxesForContext = useCallback( + async ({ accountId }: Readonly<{ accountId: string }>): Promise => { + return await Inboxes.listPublicAccountInboxes({ accountId, syncServerUri }); + }, + [syncServerUri], + ); + + const getAccountInboxForContext = useCallback( + async ({ + accountId, + inboxId, + }: Readonly<{ accountId: string; inboxId: string }>): Promise => { + return await Inboxes.getAccountInbox({ accountId, inboxId, syncServerUri }); + }, + [syncServerUri], + ); + + const sendAccountInboxMessageForContext = useCallback( + async ({ + message, + accountId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }: Readonly<{ + message: string; + accountId: string; + inboxId: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string | null; + }>) => { + return await Inboxes.sendAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + syncServerUri, + authorAccountId, + }); + }, + [syncServerUri], + ); + const acceptInvitationForContext = useCallback( async ({ invitation, @@ -630,9 +1234,6 @@ export function HypergraphAppProvider({ if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { throw new Error('Missing keys'); } - if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { - throw new Error('Missing keys'); - } if (!space.state) { console.error('No state found for space'); return; @@ -696,6 +1297,56 @@ export function HypergraphAppProvider({ [syncServerUri], ); + const ensureSpaceInboxForContext = useCallback( + async ({ + spaceId, + isPublic = true, + authPolicy = 'anonymous', + index = 0, + }: { + spaceId: string; + isPublic?: boolean; + authPolicy?: Inboxes.InboxSenderAuthPolicy; + index?: number; + }) => { + const storeState = store.getSnapshot(); + const space = storeState.context.spaces.find((s) => s.id === spaceId); + if (!space) { + throw new Error('Space not found'); + } + + // Return existing inbox if found + if (space.inboxes[index]) { + return space.inboxes[index].inboxId; + } + + // Create new inbox + await createSpaceInboxForContext({ + space, + isPublic, + authPolicy, + }); + + // Wait for inbox to appear in store + let attempts = 0; + const maxAttempts = 10; + + while (attempts < maxAttempts) { + const storeState = store.getSnapshot(); + const updatedSpace = storeState.context.spaces.find((s) => s.id === spaceId); + if (updatedSpace?.inboxes[index]) { + return updatedSpace.inboxes[index].inboxId; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + attempts++; + } + + throw new Error('Timeout waiting for inbox to be created'); + }, + [createSpaceInboxForContext], + ); + return ( {children} diff --git a/packages/hypergraph-react/src/hooks/useExternalAccountInbox.ts b/packages/hypergraph-react/src/hooks/useExternalAccountInbox.ts new file mode 100644 index 00000000..84636961 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/useExternalAccountInbox.ts @@ -0,0 +1,79 @@ +import type { Messages } from '@graphprotocol/hypergraph'; +import { useCallback, useEffect, useState } from 'react'; +import { useHypergraphApp, useHypergraphAuth } from '../HypergraphAppContext.js'; + +/** + * Hook for interacting with external inboxes + * Provides limited capabilities for sending messages to other users' inboxes + */ +export function useExternalAccountInbox(accountId: string, inboxId: string) { + const { sendAccountInboxMessage, getAccountInbox } = useHypergraphApp(); + const { identity } = useHypergraphAuth(); + + // Use local state for external inbox + const [inbox, setInbox] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Initial fetch for external inbox + useEffect(() => { + const fetchInbox = async () => { + try { + setLoading(true); + setError(null); + + // Fetch external inbox + const fetchedInbox = await getAccountInbox({ accountId, inboxId }); + setInbox(fetchedInbox); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to get inbox')); + } finally { + setLoading(false); + } + }; + + fetchInbox(); + }, [accountId, inboxId, getAccountInbox]); + + const sendMessage = useCallback( + async (message: string) => { + if (!inbox) throw new Error('Inbox not found'); + + try { + setLoading(true); + setError(null); + + let authorAccountId: string | null = null; + let signaturePrivateKey: string | null = null; + if (identity?.accountId && inbox.authPolicy !== 'anonymous') { + authorAccountId = identity.accountId; + signaturePrivateKey = identity.signaturePrivateKey; + } + + await sendAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey: inbox.encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to send message')); + throw err; + } finally { + setLoading(false); + } + }, + [inbox, accountId, inboxId, identity, sendAccountInboxMessage], + ); + + return { + loading, + error, + sendMessage, + isPublic: inbox?.isPublic ?? false, + authPolicy: inbox?.authPolicy ?? 'unknown', + encryptionPublicKey: inbox?.encryptionPublicKey ?? '', + }; +} diff --git a/packages/hypergraph-react/src/hooks/useExternalSpaceInbox.ts b/packages/hypergraph-react/src/hooks/useExternalSpaceInbox.ts new file mode 100644 index 00000000..1fe9ff2b --- /dev/null +++ b/packages/hypergraph-react/src/hooks/useExternalSpaceInbox.ts @@ -0,0 +1,88 @@ +import { Messages } from '@graphprotocol/hypergraph'; +import { useCallback, useEffect, useState } from 'react'; +import { useHypergraphApp, useHypergraphAuth } from '../HypergraphAppContext.js'; + +/** + * Hook for interacting with external space inboxes + * Provides limited capabilities for sending messages to other spaces' inboxes + */ +export function useExternalSpaceInbox({ + spaceId, + inboxId, +}: { + spaceId: string; + inboxId: string; +}) { + const { sendSpaceInboxMessage, getSpaceInbox } = useHypergraphApp(); + const { identity } = useHypergraphAuth(); + + // Use local state for external inbox + const [inbox, setInbox] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Initial fetch for external inbox + useEffect(() => { + const fetchInbox = async () => { + try { + setLoading(true); + setError(null); + + // Fetch external inbox + const fetchedInbox = await getSpaceInbox({ spaceId, inboxId }); + setInbox(fetchedInbox); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to get inbox')); + } finally { + setLoading(false); + } + }; + + fetchInbox(); + }, [spaceId, inboxId, getSpaceInbox]); + + const sendMessage = useCallback( + async (message: string) => { + if (!inbox) throw new Error('Inbox not found'); + + try { + setLoading(true); + setError(null); + + let authorAccountId: string | null = null; + let signaturePrivateKey: string | null = null; + if (identity?.accountId && inbox.authPolicy !== 'anonymous') { + authorAccountId = identity.accountId; + signaturePrivateKey = identity.signaturePrivateKey; + } else if (inbox.authPolicy === 'requires_auth') { + throw new Error('Cannot send message to a required auth inbox without an identity'); + } + + await sendSpaceInboxMessage({ + message, + spaceId, + inboxId, + encryptionPublicKey: inbox.encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to send message')); + throw err; + } finally { + setLoading(false); + } + }, + [inbox, spaceId, inboxId, identity, sendSpaceInboxMessage], + ); + + return { + loading, + error, + sendMessage, + inboxId, + isPublic: inbox?.isPublic ?? false, + authPolicy: inbox?.authPolicy ?? 'unknown', + encryptionPublicKey: inbox?.encryptionPublicKey ?? '', + }; +} diff --git a/packages/hypergraph-react/src/hooks/useOwnAccountInbox.ts b/packages/hypergraph-react/src/hooks/useOwnAccountInbox.ts new file mode 100644 index 00000000..6d37ff93 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/useOwnAccountInbox.ts @@ -0,0 +1,101 @@ +import { store } from '@graphprotocol/hypergraph'; +import { useSelector as useSelectorStore } from '@xstate/store/react'; +import { useCallback, useEffect, useState } from 'react'; +import { useHypergraphApp, useHypergraphAuth } from '../HypergraphAppContext.js'; + +/** + * Hook for managing a user's own inbox + * Provides full read/write capabilities for the user's own inbox + */ +export function useOwnAccountInbox(inboxId: string) { + const { getLatestAccountInboxMessages, sendAccountInboxMessage, getOwnAccountInboxes } = useHypergraphApp(); + const { identity } = useHypergraphAuth(); + const accountId = identity?.accountId; + + // Get own inbox from store + const inbox = useSelectorStore(store, (state) => state.context.accountInboxes.find((i) => i.inboxId === inboxId)); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Initial fetch for own inbox + useEffect(() => { + const fetchInbox = async () => { + if (!accountId) return; + + try { + setLoading(true); + setError(null); + + // First ensure inbox is in store + await getOwnAccountInboxes(); + // Then get latest messages + await getLatestAccountInboxMessages({ accountId, inboxId }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to get inbox')); + } finally { + setLoading(false); + } + }; + + fetchInbox(); + }, [accountId, inboxId, getOwnAccountInboxes, getLatestAccountInboxMessages]); + + const refresh = useCallback(async () => { + if (!accountId) throw new Error('User not authenticated'); + + try { + setLoading(true); + setError(null); + await getLatestAccountInboxMessages({ accountId, inboxId }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to refresh messages')); + } finally { + setLoading(false); + } + }, [accountId, inboxId, getLatestAccountInboxMessages]); + + const sendMessage = useCallback( + async (message: string) => { + if (!inbox) throw new Error('Inbox not found'); + if (!accountId) throw new Error('User not authenticated'); + + try { + setLoading(true); + setError(null); + + let authorAccountId: string | null = null; + let signaturePrivateKey: string | null = null; + if (inbox.authPolicy !== 'anonymous') { + authorAccountId = accountId; + signaturePrivateKey = identity?.signaturePrivateKey || null; + } + + await sendAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey: inbox.encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to send message')); + throw err; + } finally { + setLoading(false); + } + }, + [inbox, accountId, inboxId, identity, sendAccountInboxMessage], + ); + + return { + messages: inbox?.messages ?? [], + loading, + error, + refresh, + sendMessage, + isPublic: inbox?.isPublic ?? false, + authPolicy: inbox?.authPolicy ?? 'unknown', + encryptionPublicKey: inbox?.encryptionPublicKey ?? '', + }; +} diff --git a/packages/hypergraph-react/src/hooks/useOwnSpaceInbox.ts b/packages/hypergraph-react/src/hooks/useOwnSpaceInbox.ts new file mode 100644 index 00000000..a9fead0c --- /dev/null +++ b/packages/hypergraph-react/src/hooks/useOwnSpaceInbox.ts @@ -0,0 +1,143 @@ +import { type Inboxes, store } from '@graphprotocol/hypergraph'; +import { useSelector as useSelectorStore } from '@xstate/store/react'; +import { useCallback, useEffect, useState } from 'react'; +import { useHypergraphApp, useHypergraphAuth } from '../HypergraphAppContext.js'; + +/** + * Hook for managing a user's own space inbox + * Provides full read/write capabilities for the user's own space inbox + */ +export function useOwnSpaceInbox({ + spaceId, + inboxId, + autoCreate = false, + isPublic = false, + authPolicy = 'requires_auth', +}: { + spaceId: string; + inboxId?: string; + autoCreate?: boolean; + isPublic?: boolean; + authPolicy?: Inboxes.InboxSenderAuthPolicy; +}) { + const { getLatestSpaceInboxMessages, sendSpaceInboxMessage, ensureSpaceInbox } = useHypergraphApp(); + const { identity } = useHypergraphAuth(); + + // Get own space inbox from store + const space = useSelectorStore(store, (state) => state.context.spaces.find((s) => s.id === spaceId)); + const [ownInboxId, setOwnInboxId] = useState(null); + const ownInbox = ownInboxId ? space?.inboxes.find((i) => i.inboxId === ownInboxId) : null; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const realInboxId = ownInbox?.inboxId ?? inboxId; + + // Initial fetch for own inbox + useEffect(() => { + const ensureInbox = async () => { + setLoading(true); + setError(null); + try { + if (!ownInboxId) { + if (inboxId) { + setOwnInboxId(inboxId); + } else if (space?.inboxes[0]?.inboxId) { + setOwnInboxId(space.inboxes[0].inboxId); + } else if (autoCreate) { + const inboxId = await ensureSpaceInbox({ spaceId, isPublic, authPolicy }); + setOwnInboxId(inboxId); + } + } + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to ensure inbox')); + } + }; + + const fetchInbox = async () => { + try { + if (ownInboxId) { + await getLatestSpaceInboxMessages({ spaceId, inboxId: ownInboxId }); + } + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to get inbox')); + } finally { + setLoading(false); + } + }; + + ensureInbox().then(fetchInbox); + }, [ + spaceId, + inboxId, + ownInboxId, + getLatestSpaceInboxMessages, + ensureSpaceInbox, + autoCreate, + isPublic, + authPolicy, + space?.inboxes[0]?.inboxId, + ]); + + const refresh = useCallback(async () => { + if (!ownInbox) { + return; + } + try { + setLoading(true); + setError(null); + await getLatestSpaceInboxMessages({ spaceId, inboxId: ownInbox.inboxId }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to refresh messages')); + } finally { + setLoading(false); + } + }, [spaceId, ownInbox, getLatestSpaceInboxMessages]); + + const sendMessage = useCallback( + async (message: string) => { + if (!ownInbox) throw new Error('Inbox not found'); + if (!realInboxId) throw new Error('Inbox ID not found'); + + try { + setLoading(true); + setError(null); + + let authorAccountId: string | null = null; + let signaturePrivateKey: string | null = null; + if (identity?.accountId && ownInbox.authPolicy !== 'anonymous') { + authorAccountId = identity.accountId; + signaturePrivateKey = identity.signaturePrivateKey; + } else if (ownInbox.authPolicy === 'requires_auth') { + throw new Error('Cannot send message to a required auth inbox without an identity'); + } + + await sendSpaceInboxMessage({ + message, + spaceId, + inboxId: realInboxId, + encryptionPublicKey: ownInbox.encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to send message')); + throw err; + } finally { + setLoading(false); + } + }, + [ownInbox, spaceId, realInboxId, identity, sendSpaceInboxMessage], + ); + + return { + messages: ownInbox?.messages ?? [], + loading, + error, + refresh, + sendMessage, + inboxId: realInboxId, + isPublic: ownInbox?.isPublic ?? false, + authPolicy: ownInbox?.authPolicy ?? 'unknown', + encryptionPublicKey: ownInbox?.encryptionPublicKey ?? '', + }; +} diff --git a/packages/hypergraph-react/src/hooks/usePublicAccountInboxes.ts b/packages/hypergraph-react/src/hooks/usePublicAccountInboxes.ts new file mode 100644 index 00000000..b31782bc --- /dev/null +++ b/packages/hypergraph-react/src/hooks/usePublicAccountInboxes.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; +import { useHypergraphApp } from '../HypergraphAppContext.js'; + +export function usePublicAccountInboxes(accountId: string) { + const { listPublicAccountInboxes } = useHypergraphApp(); + const [publicInboxes, setPublicInboxes] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadInboxes() { + try { + const inboxes = await listPublicAccountInboxes({ accountId }); + setPublicInboxes(inboxes.map((inbox: { inboxId: string }) => ({ inboxId: inbox.inboxId }))); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to load public inboxes')); + } finally { + setLoading(false); + } + } + loadInboxes(); + }, [accountId, listPublicAccountInboxes]); + + return { publicInboxes, loading, error }; +} diff --git a/packages/hypergraph-react/src/hooks/usePublicSpaceInboxes.ts b/packages/hypergraph-react/src/hooks/usePublicSpaceInboxes.ts new file mode 100644 index 00000000..ffc4b4f7 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/usePublicSpaceInboxes.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; +import { useHypergraphApp } from '../HypergraphAppContext.js'; + +export function usePublicSpaceInboxes(spaceId: string) { + const { listPublicSpaceInboxes } = useHypergraphApp(); + const [publicInboxes, setPublicInboxes] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadInboxes() { + try { + const inboxes = await listPublicSpaceInboxes({ spaceId }); + setPublicInboxes(inboxes.map((inbox: { inboxId: string }) => ({ inboxId: inbox.inboxId }))); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to load public space inboxes')); + } finally { + setLoading(false); + } + } + loadInboxes(); + }, [spaceId, listPublicSpaceInboxes]); + + return { publicInboxes, loading, error }; +} diff --git a/packages/hypergraph-react/src/index.ts b/packages/hypergraph-react/src/index.ts index 9d0b9aba..c0049b67 100644 --- a/packages/hypergraph-react/src/index.ts +++ b/packages/hypergraph-react/src/index.ts @@ -11,3 +11,8 @@ export { useQueryEntity, useUpdateEntity, } from './HypergraphSpaceContext.js'; +export { useOwnAccountInbox } from './hooks/useOwnAccountInbox.js'; +export { useExternalAccountInbox } from './hooks/useExternalAccountInbox.js'; +export { useOwnSpaceInbox } from './hooks/useOwnSpaceInbox.js'; +export { useExternalSpaceInbox } from './hooks/useExternalSpaceInbox.js'; +export { usePublicAccountInboxes } from './hooks/usePublicAccountInboxes.js'; diff --git a/packages/hypergraph/package.json b/packages/hypergraph/package.json index 09dab072..4d7927ab 100644 --- a/packages/hypergraph/package.json +++ b/packages/hypergraph/package.json @@ -33,6 +33,7 @@ "@noble/curves": "^1.8.0", "@noble/hashes": "^1.7.0", "@noble/secp256k1": "^2.2.3", + "@serenity-kit/noble-sodium": "^0.2.1", "@xstate/store": "^2.6.2", "bs58check": "^4.0.0", "effect": "^3.12.4", diff --git a/packages/hypergraph/src/inboxes/create-inbox.ts b/packages/hypergraph/src/inboxes/create-inbox.ts new file mode 100644 index 00000000..74673d79 --- /dev/null +++ b/packages/hypergraph/src/inboxes/create-inbox.ts @@ -0,0 +1,101 @@ +import { type Inboxes, Messages, SpaceEvents } from '@graphprotocol/hypergraph'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { randomBytes } from '@noble/hashes/utils'; +import { cryptoBoxKeyPair } from '@serenity-kit/noble-sodium'; +import { Effect } from 'effect'; +import { bytesToHex, hexToBytes, stringToUint8Array } from '../utils/index.js'; +import { canonicalize } from '../utils/index.js'; + +type CreateAccountInboxParams = { + accountId: string; + isPublic: boolean; + authPolicy: Inboxes.InboxSenderAuthPolicy; + encryptionPublicKey: string; + signaturePrivateKey: string; + + // TODO: add optional schema +}; + +type CreateSpaceInboxParams = { + author: SpaceEvents.Author; + spaceId: string; + isPublic: boolean; + authPolicy: Inboxes.InboxSenderAuthPolicy; + spaceSecretKey: string; + previousEventHash: string; +}; + +// The caller should have already verified that the accountId, signaturePrivateKey and encryptionPublicKey belong to the same account +export function createAccountInboxCreationMessage({ + accountId, + isPublic, + authPolicy, + encryptionPublicKey, + signaturePrivateKey, +}: CreateAccountInboxParams): Messages.RequestCreateAccountInbox { + // Generate a 32 byte random inbox id + const inboxId = bytesToHex(randomBytes(32)); + + // This message can prove to anyone wanting to send a message to the inbox that it is indeed from the account + // and that the public key belongs to the account + const messageToSign = stringToUint8Array( + canonicalize({ + accountId, + inboxId, + encryptionPublicKey, + }), + ); + + const signature = secp256k1.sign(messageToSign, hexToBytes(signaturePrivateKey), { prehash: true }); + + return { + type: 'create-account-inbox', + inboxId: inboxId, + accountId, + isPublic, + authPolicy, + encryptionPublicKey, + signature: { + hex: signature.toCompactHex(), + recovery: signature.recovery, + }, + } satisfies Messages.RequestCreateAccountInbox; +} + +export async function createSpaceInboxCreationMessage({ + author, + spaceId, + isPublic, + authPolicy, + spaceSecretKey, + previousEventHash, +}: CreateSpaceInboxParams): Promise { + // Same as createAccountInboxMessage but with spaceId instead of accountId, and generating a keypair for the inbox + const inboxId = bytesToHex(randomBytes(32)); + const { publicKey, privateKey } = cryptoBoxKeyPair(); + + // encrypt the inbox secret key with the space secret key + const encryptedInboxSecretKey = Messages.encryptMessage({ + message: privateKey, + secretKey: hexToBytes(spaceSecretKey), + }); + + const spaceEvent = await Effect.runPromise( + SpaceEvents.createInbox({ + spaceId, + inboxId, + encryptionPublicKey: bytesToHex(publicKey), + secretKey: bytesToHex(encryptedInboxSecretKey), + isPublic, + authPolicy, + author, + previousEventHash, + }), + ); + + return { + type: 'create-space-inbox-event', + spaceId, + event: spaceEvent, + } satisfies Messages.RequestCreateSpaceInboxEvent; +} diff --git a/packages/hypergraph/src/inboxes/get-list-inboxes.ts b/packages/hypergraph/src/inboxes/get-list-inboxes.ts new file mode 100644 index 00000000..1dfdf263 --- /dev/null +++ b/packages/hypergraph/src/inboxes/get-list-inboxes.ts @@ -0,0 +1,48 @@ +import { Messages } from '@graphprotocol/hypergraph'; +import { Schema } from 'effect'; + +export const listPublicSpaceInboxes = async ({ + spaceId, + syncServerUri, +}: Readonly<{ spaceId: string; syncServerUri: string }>): Promise => { + const res = await fetch(new URL(`/spaces/${spaceId}/inboxes`, syncServerUri), { + method: 'GET', + }); + const decoded = Schema.decodeUnknownSync(Messages.ResponseListSpaceInboxesPublic)(await res.json()); + return decoded.inboxes; +}; + +export const listPublicAccountInboxes = async ({ + accountId, + syncServerUri, +}: Readonly<{ accountId: string; syncServerUri: string }>): Promise => { + const res = await fetch(new URL(`/accounts/${accountId}/inboxes`, syncServerUri), { + method: 'GET', + }); + const decoded = Schema.decodeUnknownSync(Messages.ResponseListAccountInboxesPublic)(await res.json()); + return decoded.inboxes; +}; + +export const getSpaceInbox = async ({ + spaceId, + inboxId, + syncServerUri, +}: Readonly<{ spaceId: string; inboxId: string; syncServerUri: string }>): Promise => { + const res = await fetch(new URL(`/spaces/${spaceId}/inboxes/${inboxId}`, syncServerUri), { + method: 'GET', + }); + const decoded = Schema.decodeUnknownSync(Messages.ResponseSpaceInboxPublic)(await res.json()); + return decoded.inbox; +}; + +export const getAccountInbox = async ({ + accountId, + inboxId, + syncServerUri, +}: Readonly<{ accountId: string; inboxId: string; syncServerUri: string }>): Promise => { + const res = await fetch(new URL(`/accounts/${accountId}/inboxes/${inboxId}`, syncServerUri), { + method: 'GET', + }); + const decoded = Schema.decodeUnknownSync(Messages.ResponseAccountInboxPublic)(await res.json()); + return decoded.inbox; +}; diff --git a/packages/hypergraph/src/inboxes/index.ts b/packages/hypergraph/src/inboxes/index.ts new file mode 100644 index 00000000..3a770156 --- /dev/null +++ b/packages/hypergraph/src/inboxes/index.ts @@ -0,0 +1,10 @@ +export * from './create-inbox.js'; +export * from './get-list-inboxes.js'; +export * from './message-encryption.js'; +export * from './message-validation.js'; +export * from './prepare-message.js'; +export * from './recover-inbox-creator.js'; +export * from './recover-inbox-message-signer.js'; +export * from './send-message.js'; +export * from './merge-messages.js'; +export * from './types.js'; diff --git a/packages/hypergraph/src/inboxes/merge-messages.ts b/packages/hypergraph/src/inboxes/merge-messages.ts new file mode 100644 index 00000000..75bfafcb --- /dev/null +++ b/packages/hypergraph/src/inboxes/merge-messages.ts @@ -0,0 +1,28 @@ +import type { InboxMessageStorageEntry } from '../store.js'; + +export function mergeMessages( + existingMessages: InboxMessageStorageEntry[], + existingSeenIds: Set, + newMessages: InboxMessageStorageEntry[], +) { + const messages = [...existingMessages]; + const seenMessageIds = new Set(existingSeenIds); + + // Filter and add new messages + const newFilteredMessages = newMessages.filter((msg) => !seenMessageIds.has(msg.id)); + for (const msg of newFilteredMessages) { + seenMessageIds.add(msg.id); + } + + if (newFilteredMessages.length > 0) { + // Only sort if the last new message is earlier than the last existing message + if (messages.length > 0 && newFilteredMessages[0].createdAt < messages[messages.length - 1].createdAt) { + messages.push(...newFilteredMessages); + messages.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); + } else { + messages.push(...newFilteredMessages); + } + } + + return { messages, seenMessageIds }; +} diff --git a/packages/hypergraph/src/inboxes/message-encryption.ts b/packages/hypergraph/src/inboxes/message-encryption.ts new file mode 100644 index 00000000..68c3ba47 --- /dev/null +++ b/packages/hypergraph/src/inboxes/message-encryption.ts @@ -0,0 +1,35 @@ +import { cryptoBoxSeal, cryptoBoxSealOpen } from '@serenity-kit/noble-sodium'; +import { bytesToHex, hexToBytes, stringToUint8Array, uint8ArrayToString } from '../utils/index.js'; + +type EncryptParams = { + message: string; + encryptionPublicKey: string; +}; + +type DecryptParams = { + ciphertext: string; + encryptionPrivateKey: string; + encryptionPublicKey: string; +}; + +export function encryptInboxMessage({ message, encryptionPublicKey }: EncryptParams): { + ciphertext: string; +} { + const ciphertext = cryptoBoxSeal({ + message: stringToUint8Array(message), + publicKey: hexToBytes(encryptionPublicKey), + }); + + return { ciphertext: bytesToHex(ciphertext) }; +} + +export function decryptInboxMessage({ ciphertext, encryptionPrivateKey, encryptionPublicKey }: DecryptParams): string { + const publicKey = hexToBytes(encryptionPublicKey); + const privateKey = hexToBytes(encryptionPrivateKey); + const message = cryptoBoxSealOpen({ + ciphertext: hexToBytes(ciphertext), + privateKey, + publicKey, + }); + return uint8ArrayToString(message); +} diff --git a/packages/hypergraph/src/inboxes/message-validation.ts b/packages/hypergraph/src/inboxes/message-validation.ts new file mode 100644 index 00000000..ec07016c --- /dev/null +++ b/packages/hypergraph/src/inboxes/message-validation.ts @@ -0,0 +1,65 @@ +import { Identity, Messages } from '@graphprotocol/hypergraph'; +import type { AccountInboxStorageEntry, SpaceInboxStorageEntry } from '../store.js'; +import { recoverAccountInboxMessageSigner, recoverSpaceInboxMessageSigner } from './recover-inbox-message-signer.js'; + +export const validateSpaceInboxMessage = async ( + message: Messages.InboxMessage, + inbox: SpaceInboxStorageEntry, + spaceId: string, + syncServerUri: string, +) => { + if (message.signature) { + if (inbox.authPolicy === 'anonymous') { + console.error('Signed message in anonymous inbox'); + return false; + } + if (!message.authorAccountId) { + console.error('Signed message without authorAccountId'); + return false; + } + const signer = recoverSpaceInboxMessageSigner(message, spaceId, inbox.inboxId); + const verifiedIdentity = await Identity.getVerifiedIdentity(message.authorAccountId, syncServerUri); + const isValid = signer === verifiedIdentity.signaturePublicKey; + if (!isValid) { + console.error('Invalid signature', signer, verifiedIdentity.signaturePublicKey); + } + return isValid; + } + // Unsigned message is valid if the inbox is anonymous or optional auth + const isValid = inbox.authPolicy !== 'requires_auth'; + if (!isValid) { + console.error('Unsigned message in required auth inbox'); + } + return isValid; +}; + +export const validateAccountInboxMessage = async ( + message: Messages.InboxMessage, + inbox: AccountInboxStorageEntry, + accountId: string, + syncServerUri: string, +) => { + if (message.signature) { + if (inbox.authPolicy === 'anonymous') { + console.error('Signed message in anonymous inbox'); + return false; + } + if (!message.authorAccountId) { + console.error('Signed message without authorAccountId'); + return false; + } + const signer = recoverAccountInboxMessageSigner(message, accountId, inbox.inboxId); + const verifiedIdentity = await Identity.getVerifiedIdentity(message.authorAccountId, syncServerUri); + const isValid = signer === verifiedIdentity.signaturePublicKey; + if (!isValid) { + console.error('Invalid signature', signer, verifiedIdentity.signaturePublicKey); + } + return isValid; + } + // Unsigned message is valid if the inbox is anonymous or optional auth + const isValid = inbox.authPolicy !== 'requires_auth'; + if (!isValid) { + console.error('Unsigned message in required auth inbox'); + } + return isValid; +}; diff --git a/packages/hypergraph/src/inboxes/prepare-message.ts b/packages/hypergraph/src/inboxes/prepare-message.ts new file mode 100644 index 00000000..ae3ca049 --- /dev/null +++ b/packages/hypergraph/src/inboxes/prepare-message.ts @@ -0,0 +1,85 @@ +import type { Messages } from '@graphprotocol/hypergraph'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import type { SignatureWithRecovery } from '../types.js'; +import { canonicalize, hexToBytes, stringToUint8Array } from '../utils/index.js'; +import { encryptInboxMessage } from './message-encryption.js'; + +export async function prepareSpaceInboxMessage({ + message, + spaceId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, +}: Readonly<{ + message: string; + spaceId: string; + inboxId: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string | null; +}>) { + const { ciphertext } = encryptInboxMessage({ message, encryptionPublicKey }); + let signature: SignatureWithRecovery | undefined; + if (signaturePrivateKey && authorAccountId) { + const messageToSign = stringToUint8Array( + canonicalize({ + spaceId, + inboxId, + ciphertext, + authorAccountId, + }), + ); + const signatureInstance = secp256k1.sign(messageToSign, hexToBytes(signaturePrivateKey), { prehash: true }); + signature = { + hex: signatureInstance.toCompactHex(), + recovery: signatureInstance.recovery, + }; + } + const messageToSend: Messages.RequestCreateSpaceInboxMessage = { + ciphertext, + signature, + authorAccountId: authorAccountId ?? undefined, + }; + return messageToSend; +} + +export async function prepareAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, +}: Readonly<{ + message: string; + accountId: string; + inboxId: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string | null; +}>) { + const { ciphertext } = encryptInboxMessage({ message, encryptionPublicKey }); + let signature: SignatureWithRecovery | undefined; + if (signaturePrivateKey && authorAccountId) { + const messageToSign = stringToUint8Array( + canonicalize({ + accountId, + inboxId, + ciphertext, + authorAccountId, + }), + ); + const signatureInstance = secp256k1.sign(messageToSign, hexToBytes(signaturePrivateKey), { prehash: true }); + signature = { + hex: signatureInstance.toCompactHex(), + recovery: signatureInstance.recovery, + }; + } + const messageToSend: Messages.RequestCreateAccountInboxMessage = { + ciphertext, + signature, + authorAccountId: authorAccountId ?? undefined, + }; + return messageToSend; +} diff --git a/packages/hypergraph/src/inboxes/recover-inbox-creator.ts b/packages/hypergraph/src/inboxes/recover-inbox-creator.ts new file mode 100644 index 00000000..b1863fdc --- /dev/null +++ b/packages/hypergraph/src/inboxes/recover-inbox-creator.ts @@ -0,0 +1,30 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import type { AccountInbox } from '../messages/index.js'; +import type { CreateSpaceInboxEvent } from '../space-events/index.js'; +import { stringToUint8Array } from '../utils/index.js'; +import { canonicalize } from '../utils/index.js'; + +export const recoverAccountInboxCreatorKey = (inbox: AccountInbox): string => { + const messageToVerify = stringToUint8Array( + canonicalize({ + accountId: inbox.accountId, + inboxId: inbox.inboxId, + encryptionPublicKey: inbox.encryptionPublicKey, + }), + ); + const signature = inbox.signature; + let signatureInstance = secp256k1.Signature.fromCompact(signature.hex); + signatureInstance = signatureInstance.addRecoveryBit(signature.recovery); + const authorPublicKey = `0x${signatureInstance.recoverPublicKey(sha256(messageToVerify)).toHex()}`; + return authorPublicKey; +}; + +export const recoverSpaceInboxCreatorKey = (event: CreateSpaceInboxEvent): string => { + const messageToVerify = stringToUint8Array(canonicalize(event.transaction)); + const signature = event.author.signature; + let signatureInstance = secp256k1.Signature.fromCompact(signature.hex); + signatureInstance = signatureInstance.addRecoveryBit(signature.recovery); + const authorPublicKey = `0x${signatureInstance.recoverPublicKey(sha256(messageToVerify)).toHex()}`; + return authorPublicKey; +}; diff --git a/packages/hypergraph/src/inboxes/recover-inbox-message-signer.ts b/packages/hypergraph/src/inboxes/recover-inbox-message-signer.ts new file mode 100644 index 00000000..42ecb334 --- /dev/null +++ b/packages/hypergraph/src/inboxes/recover-inbox-message-signer.ts @@ -0,0 +1,41 @@ +import { type Messages, Utils } from '@graphprotocol/hypergraph'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; + +export const recoverSpaceInboxMessageSigner = ( + message: Messages.RequestCreateSpaceInboxMessage, + spaceId: string, + inboxId: string, +) => { + if (!message.signature) { + throw new Error('Signature is required'); + } + let signatureInstance = secp256k1.Signature.fromCompact(message.signature.hex); + signatureInstance = signatureInstance.addRecoveryBit(message.signature.recovery); + const signedMessage = { + spaceId, + inboxId, + ciphertext: message.ciphertext, + authorAccountId: message.authorAccountId, + }; + return `0x${signatureInstance.recoverPublicKey(sha256(Utils.stringToUint8Array(Utils.canonicalize(signedMessage)))).toHex()}`; +}; + +export const recoverAccountInboxMessageSigner = ( + message: Messages.RequestCreateAccountInboxMessage, + accountId: string, + inboxId: string, +) => { + if (!message.signature) { + throw new Error('Signature is required'); + } + let signatureInstance = secp256k1.Signature.fromCompact(message.signature.hex); + signatureInstance = signatureInstance.addRecoveryBit(message.signature.recovery); + const signedMessage = { + accountId, + inboxId, + ciphertext: message.ciphertext, + authorAccountId: message.authorAccountId, + }; + return `0x${signatureInstance.recoverPublicKey(sha256(Utils.stringToUint8Array(Utils.canonicalize(signedMessage)))).toHex()}`; +}; diff --git a/packages/hypergraph/src/inboxes/send-message.ts b/packages/hypergraph/src/inboxes/send-message.ts new file mode 100644 index 00000000..df76f1a8 --- /dev/null +++ b/packages/hypergraph/src/inboxes/send-message.ts @@ -0,0 +1,75 @@ +import { prepareAccountInboxMessage, prepareSpaceInboxMessage } from './prepare-message.js'; + +export async function sendSpaceInboxMessage({ + message, + spaceId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + syncServerUri, +}: Readonly<{ + message: string; + spaceId: string; + inboxId: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string | null; + syncServerUri: string; +}>) { + const messageToSend = await prepareSpaceInboxMessage({ + message, + spaceId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }); + const res = await fetch(new URL(`/spaces/${spaceId}/inboxes/${inboxId}/messages`, syncServerUri), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(messageToSend), + }); + if (!res.ok) { + throw new Error('Failed to send message'); + } +} + +export async function sendAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + syncServerUri, +}: Readonly<{ + message: string; + accountId: string; + inboxId: string; + encryptionPublicKey: string; + signaturePrivateKey: string | null; + authorAccountId: string | null; + syncServerUri: string; +}>) { + const messageToSend = await prepareAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey, + signaturePrivateKey, + authorAccountId, + }); + const res = await fetch(new URL(`/accounts/${accountId}/inboxes/${inboxId}/messages`, syncServerUri), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(messageToSend), + }); + if (!res.ok) { + throw new Error('Failed to send message'); + } +} diff --git a/packages/hypergraph/src/inboxes/types.ts b/packages/hypergraph/src/inboxes/types.ts new file mode 100644 index 00000000..00f730ed --- /dev/null +++ b/packages/hypergraph/src/inboxes/types.ts @@ -0,0 +1,9 @@ +import * as Schema from 'effect/Schema'; + +export const InboxSenderAuthPolicy = Schema.Union( + Schema.Literal('anonymous'), + Schema.Literal('optional_auth'), + Schema.Literal('requires_auth'), +); + +export type InboxSenderAuthPolicy = Schema.Schema.Type; diff --git a/packages/hypergraph/src/index.ts b/packages/hypergraph/src/index.ts index 9a995eca..45513bcd 100644 --- a/packages/hypergraph/src/index.ts +++ b/packages/hypergraph/src/index.ts @@ -3,5 +3,6 @@ export * as Identity from './identity/index.js'; export * as Key from './key/index.js'; export * as Messages from './messages/index.js'; export * as SpaceEvents from './space-events/index.js'; +export * as Inboxes from './inboxes/index.js'; export * from './store.js'; export * as Utils from './utils/index.js'; diff --git a/packages/hypergraph/src/key/key-box.ts b/packages/hypergraph/src/key/key-box.ts index c304bef1..66bc7671 100644 --- a/packages/hypergraph/src/key/key-box.ts +++ b/packages/hypergraph/src/key/key-box.ts @@ -1,41 +1,20 @@ -import { xchacha20poly1305 } from '@noble/ciphers/chacha'; -import { x25519 } from '@noble/curves/ed25519'; -import { blake2b } from '@noble/hashes/blake2b'; +import { cryptoBoxEasy, cryptoBoxKeyPair, cryptoBoxOpenEasy } from '@serenity-kit/noble-sodium'; -const NONCE_LENGTH = 24; -const MAC_LENGTH = 16; +export function generateKeypair(): { + publicKey: Uint8Array; + secretKey: Uint8Array; +} { + const { publicKey, privateKey } = cryptoBoxKeyPair(); + return { publicKey, secretKey: privateKey }; +} -type EncryptKeyBoxParams = { +export type EncryptKeyBoxParams = { message: Uint8Array; nonce: Uint8Array; publicKey: Uint8Array; secretKey: Uint8Array; }; -export function generateKeypair() { - const secretKey = x25519.utils.randomPrivateKey(); - const publicKey = x25519.getPublicKey(secretKey); - return { publicKey, secretKey }; -} - -export function encryptKeyBox({ message, nonce, publicKey, secretKey }: EncryptKeyBoxParams): Uint8Array { - if (nonce.length !== NONCE_LENGTH) { - throw new Error(`Nonce must be ${NONCE_LENGTH} bytes`); - } - - // Compute shared key using X25519 - const sharedSecret = x25519.getSharedSecret(secretKey, publicKey); - - // Derive symmetric key using BLAKE2b - const key = blake2b.create({ dkLen: 32 }).update(sharedSecret).digest(); - - // Encrypt using XChaCha20-Poly1305 - const cipher = xchacha20poly1305(key, nonce); - const ciphertext = cipher.encrypt(message); - - return ciphertext; -} - export type DecryptKeyBoxParams = { ciphertext: Uint8Array; nonce: Uint8Array; @@ -43,24 +22,10 @@ export type DecryptKeyBoxParams = { secretKey: Uint8Array; }; -export function decryptKeyBox({ ciphertext, nonce, publicKey, secretKey }: DecryptKeyBoxParams): Uint8Array { - if (nonce.length !== NONCE_LENGTH) { - throw new Error(`Nonce must be ${NONCE_LENGTH} bytes`); - } - - if (ciphertext.length < MAC_LENGTH) { - throw new Error('Ciphertext too short'); - } - - // Compute shared key using X25519 - const sharedSecret = x25519.getSharedSecret(secretKey, publicKey); - - // Derive symmetric key using BLAKE2b - const key = blake2b.create({ dkLen: 32 }).update(sharedSecret).digest(); - - // Decrypt using XChaCha20-Poly1305 - const cipher = xchacha20poly1305(key, nonce); - const plaintext = cipher.decrypt(ciphertext); +export function encryptKeyBox({ message, publicKey, secretKey, nonce }: EncryptKeyBoxParams): Uint8Array { + return cryptoBoxEasy({ message, publicKey, privateKey: secretKey, nonce }); +} - return plaintext; +export function decryptKeyBox({ ciphertext, nonce, publicKey, secretKey }: DecryptKeyBoxParams): Uint8Array { + return cryptoBoxOpenEasy({ ciphertext, publicKey, privateKey: secretKey, nonce }); } diff --git a/packages/hypergraph/src/messages/serialize.ts b/packages/hypergraph/src/messages/serialize.ts index c31f25dc..1950fead 100644 --- a/packages/hypergraph/src/messages/serialize.ts +++ b/packages/hypergraph/src/messages/serialize.ts @@ -4,6 +4,9 @@ export function serialize(obj: any): string { if (value instanceof Uint8Array) { return { __type: 'Uint8Array', data: Array.from(value) }; } + if (value instanceof Date) { + return { __type: 'Date', data: value.toISOString() }; + } return value; }); } @@ -13,6 +16,9 @@ export function deserialize(json: string): unknown { if (value && value.__type === 'Uint8Array') { return value.data; } + if (value && value.__type === 'Date') { + return new Date(value.data); + } return value; }); } diff --git a/packages/hypergraph/src/messages/types.ts b/packages/hypergraph/src/messages/types.ts index 406418b5..55834ec2 100644 --- a/packages/hypergraph/src/messages/types.ts +++ b/packages/hypergraph/src/messages/types.ts @@ -1,8 +1,14 @@ import * as Schema from 'effect/Schema'; -import { AcceptInvitationEvent, CreateInvitationEvent, CreateSpaceEvent, SpaceEvent } from '../space-events/index.js'; +import { InboxSenderAuthPolicy } from '../inboxes/types.js'; +import { + AcceptInvitationEvent, + CreateInvitationEvent, + CreateSpaceEvent, + CreateSpaceInboxEvent, + SpaceEvent, +} from '../space-events/index.js'; import { SignatureWithRecovery } from '../types.js'; - export const SignedUpdate = Schema.Struct({ update: Schema.Uint8Array, accountId: Schema.String, @@ -98,6 +104,50 @@ export const RequestCreateUpdate = Schema.Struct({ signature: SignatureWithRecovery, }); +export const RequestCreateAccountInbox = Schema.Struct({ + type: Schema.Literal('create-account-inbox'), + accountId: Schema.String, + inboxId: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, + signature: SignatureWithRecovery, +}); + +export type RequestCreateAccountInbox = Schema.Schema.Type; + +export const RequestCreateSpaceInboxEvent = Schema.Struct({ + type: Schema.Literal('create-space-inbox-event'), + spaceId: Schema.String, + event: CreateSpaceInboxEvent, +}); + +export type RequestCreateSpaceInboxEvent = Schema.Schema.Type; + +export const RequestGetLatestSpaceInboxMessages = Schema.Struct({ + type: Schema.Literal('get-latest-space-inbox-messages'), + spaceId: Schema.String, + inboxId: Schema.String, + since: Schema.Date, +}); + +export type RequestGetLatestSpaceInboxMessages = Schema.Schema.Type; + +export const RequestGetLatestAccountInboxMessages = Schema.Struct({ + type: Schema.Literal('get-latest-account-inbox-messages'), + accountId: Schema.String, + inboxId: Schema.String, + since: Schema.Date, +}); + +export type RequestGetLatestAccountInboxMessages = Schema.Schema.Type; + +export const RequestGetAccountInboxes = Schema.Struct({ + type: Schema.Literal('get-account-inboxes'), +}); + +export type RequestGetAccountInboxes = Schema.Schema.Type; + export const RequestMessage = Schema.Union( RequestCreateSpaceEvent, RequestCreateInvitationEvent, @@ -106,6 +156,11 @@ export const RequestMessage = Schema.Union( RequestListSpaces, RequestListInvitations, RequestCreateUpdate, + RequestCreateAccountInbox, + RequestCreateSpaceInboxEvent, + RequestGetLatestSpaceInboxMessages, + RequestGetLatestAccountInboxMessages, + RequestGetAccountInboxes, ); export type RequestMessage = Schema.Schema.Type; @@ -147,6 +202,22 @@ export const RequestCreateIdentity = Schema.Struct({ export type RequestCreateIdentity = Schema.Schema.Type; +export const RequestCreateSpaceInboxMessage = Schema.Struct({ + ciphertext: Schema.String, + signature: Schema.optional(SignatureWithRecovery), + authorAccountId: Schema.optional(Schema.String), +}); + +export type RequestCreateSpaceInboxMessage = Schema.Schema.Type; + +export const RequestCreateAccountInboxMessage = Schema.Struct({ + ciphertext: Schema.String, + signature: Schema.optional(SignatureWithRecovery), + authorAccountId: Schema.optional(Schema.String), +}); + +export type RequestCreateAccountInboxMessage = Schema.Schema.Type; + export const ResponseListSpaces = Schema.Struct({ type: Schema.Literal('list-spaces'), spaces: Schema.Array( @@ -181,12 +252,51 @@ export const ResponseSpaceEvent = Schema.Struct({ export type ResponseSpaceEvent = Schema.Schema.Type; +export const InboxMessage = Schema.Struct({ + id: Schema.String, + ciphertext: Schema.String, + signature: Schema.optional(SignatureWithRecovery), + authorAccountId: Schema.optional(Schema.String), + createdAt: Schema.Date, +}); + +export type InboxMessage = Schema.Schema.Type; + +export const SpaceInbox = Schema.Struct({ + inboxId: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, + secretKey: Schema.String, +}); + +export type SpaceInbox = Schema.Schema.Type; + +export const AccountInbox = Schema.Struct({ + accountId: Schema.String, + inboxId: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, + signature: SignatureWithRecovery, +}); + +export type AccountInbox = Schema.Schema.Type; + +export const ResponseAccountInbox = Schema.Struct({ + type: Schema.Literal('account-inbox'), + inbox: AccountInbox, +}); + +export type ResponseAccountInbox = Schema.Schema.Type; + export const ResponseSpace = Schema.Struct({ type: Schema.Literal('space'), id: Schema.String, events: Schema.Array(SpaceEvent), keyBoxes: Schema.Array(KeyBoxWithKeyId), updates: Schema.optional(Updates), + inboxes: Schema.Array(SpaceInbox), }); export type ResponseSpace = Schema.Schema.Type; @@ -208,6 +318,49 @@ export const ResponseUpdatesNotification = Schema.Struct({ export type ResponseUpdatesNotification = Schema.Schema.Type; +export const ResponseSpaceInboxMessage = Schema.Struct({ + type: Schema.Literal('space-inbox-message'), + spaceId: Schema.String, + inboxId: Schema.String, + message: InboxMessage, +}); + +export type ResponseSpaceInboxMessage = Schema.Schema.Type; + +export const ResponseSpaceInboxMessages = Schema.Struct({ + type: Schema.Literal('space-inbox-messages'), + spaceId: Schema.String, + inboxId: Schema.String, + messages: Schema.Array(InboxMessage), +}); + +export type ResponseSpaceInboxMessages = Schema.Schema.Type; + +export const ResponseAccountInboxMessage = Schema.Struct({ + type: Schema.Literal('account-inbox-message'), + accountId: Schema.String, + inboxId: Schema.String, + message: InboxMessage, +}); + +export type ResponseAccountInboxMessage = Schema.Schema.Type; + +export const ResponseAccountInboxMessages = Schema.Struct({ + type: Schema.Literal('account-inbox-messages'), + accountId: Schema.String, + inboxId: Schema.String, + messages: Schema.Array(InboxMessage), +}); + +export type ResponseAccountInboxMessages = Schema.Schema.Type; + +export const ResponseAccountInboxes = Schema.Struct({ + type: Schema.Literal('account-inboxes'), + inboxes: Schema.Array(AccountInbox), +}); + +export type ResponseAccountInboxes = Schema.Schema.Type; + export const ResponseMessage = Schema.Union( ResponseListSpaces, ResponseListInvitations, @@ -215,6 +368,12 @@ export const ResponseMessage = Schema.Union( ResponseSpaceEvent, ResponseUpdateConfirmed, ResponseUpdatesNotification, + ResponseAccountInbox, + ResponseSpaceInboxMessage, + ResponseSpaceInboxMessages, + ResponseAccountInboxMessage, + ResponseAccountInboxMessages, + ResponseAccountInboxes, ); export type ResponseMessage = Schema.Schema.Type; @@ -253,6 +412,51 @@ export const ResponseIdentity = Schema.Struct({ export type ResponseIdentity = Schema.Schema.Type; +export const SpaceInboxPublic = Schema.Struct({ + inboxId: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, + creationEvent: CreateSpaceInboxEvent, +}); + +export type SpaceInboxPublic = Schema.Schema.Type; + +export const ResponseSpaceInboxPublic = Schema.Struct({ + inbox: SpaceInboxPublic, +}); + +export type ResponseSpaceInboxPublic = Schema.Schema.Type; + +export const ResponseListSpaceInboxesPublic = Schema.Struct({ + inboxes: Schema.Array(SpaceInboxPublic), +}); + +export type ResponseListSpaceInboxesPublic = Schema.Schema.Type; + +export const AccountInboxPublic = Schema.Struct({ + accountId: Schema.String, + inboxId: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, + signature: SignatureWithRecovery, +}); + +export type AccountInboxPublic = Schema.Schema.Type; + +export const ResponseAccountInboxPublic = Schema.Struct({ + inbox: AccountInboxPublic, +}); + +export type ResponseAccountInboxPublic = Schema.Schema.Type; + +export const ResponseListAccountInboxesPublic = Schema.Struct({ + inboxes: Schema.Array(AccountInboxPublic), +}); + +export type ResponseListAccountInboxesPublic = Schema.Schema.Type; + export const ResponseIdentityNotFoundError = Schema.Struct({ accountId: Schema.String, }); diff --git a/packages/hypergraph/src/space-events/apply-event.ts b/packages/hypergraph/src/space-events/apply-event.ts index dfb90a1d..30d05a90 100644 --- a/packages/hypergraph/src/space-events/apply-event.ts +++ b/packages/hypergraph/src/space-events/apply-event.ts @@ -8,6 +8,7 @@ import { type ApplyError, InvalidEventError, SpaceEvent, + type SpaceInbox, type SpaceInvitation, type SpaceMember, type SpaceState, @@ -58,7 +59,7 @@ export const applyEvent = ({ let members: { [accountId: string]: SpaceMember } = {}; let removedMembers: { [accountId: string]: SpaceMember } = {}; let invitations: { [id: string]: SpaceInvitation } = {}; - + let inboxes: { [inboxId: string]: SpaceInbox } = {}; if (event.transaction.type === 'create-space') { id = event.transaction.id; members[event.transaction.creatorAccountId] = { @@ -70,7 +71,7 @@ export const applyEvent = ({ members = { ...state.members }; removedMembers = { ...state.removedMembers }; invitations = { ...state.invitations }; - + inboxes = { ...state.inboxes }; if (event.transaction.type === 'accept-invitation') { // is already a member if (members[event.author.accountId] !== undefined) { @@ -119,6 +120,17 @@ export const applyEvent = ({ invitations[event.transaction.id] = { inviteeAccountId: event.transaction.inviteeAccountId, }; + } else if (event.transaction.type === 'create-space-inbox') { + if (inboxes[event.transaction.inboxId] !== undefined) { + yield* Effect.fail(new InvalidEventError()); + } + inboxes[event.transaction.inboxId] = { + inboxId: event.transaction.inboxId, + encryptionPublicKey: event.transaction.encryptionPublicKey, + isPublic: event.transaction.isPublic, + authPolicy: event.transaction.authPolicy, + secretKey: event.transaction.secretKey, + }; } else { // state is required for all events except create-space yield* Effect.fail(new InvalidEventError()); @@ -131,6 +143,7 @@ export const applyEvent = ({ members, removedMembers, invitations, + inboxes, lastEventHash: hashEvent(event), }; }); diff --git a/packages/hypergraph/src/space-events/create-inbox.ts b/packages/hypergraph/src/space-events/create-inbox.ts new file mode 100644 index 00000000..6bb90b5b --- /dev/null +++ b/packages/hypergraph/src/space-events/create-inbox.ts @@ -0,0 +1,56 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Effect } from 'effect'; +import type { InboxSenderAuthPolicy } from '../inboxes/types.js'; +import { canonicalize, generateId, hexToBytes, stringToUint8Array } from '../utils/index.js'; +import type { Author, CreateSpaceInboxEvent } from './types.js'; + +export const createInbox = ({ + author, + spaceId, + inboxId, + encryptionPublicKey, + secretKey, + isPublic, + authPolicy, + previousEventHash, +}: { + author: Author; + spaceId: string; + inboxId: string; + encryptionPublicKey: string; + previousEventHash: string; + secretKey: string; + isPublic: boolean; + authPolicy: InboxSenderAuthPolicy; +}): Effect.Effect => { + const transaction = { + type: 'create-space-inbox' as const, + id: generateId(), + spaceId, + inboxId, + encryptionPublicKey, + secretKey, + isPublic, + authPolicy, + previousEventHash, + }; + const signature = secp256k1.sign( + stringToUint8Array(canonicalize(transaction)), + hexToBytes(author.signaturePrivateKey), + { prehash: true }, + ); + + // Create a SpaceEvent to create the inbox and sign it + const spaceEvent = { + transaction, + author: { + accountId: author.accountId, + signature: { + hex: signature.toCompactHex(), + recovery: signature.recovery, + }, + }, + } satisfies CreateSpaceInboxEvent; + + return Effect.succeed(spaceEvent); +}; diff --git a/packages/hypergraph/src/space-events/index.ts b/packages/hypergraph/src/space-events/index.ts index 5e3167aa..8733e335 100644 --- a/packages/hypergraph/src/space-events/index.ts +++ b/packages/hypergraph/src/space-events/index.ts @@ -1,5 +1,6 @@ export * from './accept-invitation.js'; export * from './apply-event.js'; +export * from './create-inbox.js'; export * from './create-invitation.js'; export * from './create-space.js'; export * from './delete-space.js'; diff --git a/packages/hypergraph/src/space-events/types.ts b/packages/hypergraph/src/space-events/types.ts index 47fab696..3486127a 100644 --- a/packages/hypergraph/src/space-events/types.ts +++ b/packages/hypergraph/src/space-events/types.ts @@ -1,6 +1,7 @@ import type { ParseError } from 'effect/ParseResult'; import * as Schema from 'effect/Schema'; -import { InvalidIdentityError } from '../identity/types.js'; +import type { InvalidIdentityError } from '../identity/types.js'; +import { InboxSenderAuthPolicy } from '../inboxes/types.js'; import { SignatureWithRecovery } from '../types.js'; export const EventAuthor = Schema.Struct({ @@ -23,11 +24,22 @@ export const SpaceInvitation = Schema.Struct({ export type SpaceInvitation = Schema.Schema.Type; +export const SpaceInbox = Schema.Struct({ + inboxId: Schema.String, + encryptionPublicKey: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + secretKey: Schema.String, +}); + +export type SpaceInbox = Schema.Schema.Type; + export const SpaceState = Schema.Struct({ id: Schema.String, invitations: Schema.Record({ key: Schema.String, value: SpaceInvitation }), members: Schema.Record({ key: Schema.String, value: SpaceMember }), removedMembers: Schema.Record({ key: Schema.String, value: SpaceMember }), + inboxes: Schema.Record({ key: Schema.String, value: SpaceInbox }), lastEventHash: Schema.String, }); @@ -67,6 +79,23 @@ export const CreateInvitationEvent = Schema.Struct({ export type CreateInvitationEvent = Schema.Schema.Type; +export const CreateSpaceInboxEvent = Schema.Struct({ + transaction: Schema.Struct({ + type: Schema.Literal('create-space-inbox'), + id: Schema.String, + spaceId: Schema.String, + inboxId: Schema.String, + encryptionPublicKey: Schema.String, + secretKey: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + previousEventHash: Schema.String, + }), + author: EventAuthor, +}); + +export type CreateSpaceInboxEvent = Schema.Schema.Type; + export const AcceptInvitationEvent = Schema.Struct({ transaction: Schema.Struct({ id: Schema.String, @@ -83,6 +112,7 @@ export const SpaceEvent = Schema.Union( DeleteSpaceEvent, CreateInvitationEvent, AcceptInvitationEvent, + CreateSpaceInboxEvent, ); export type SpaceEvent = Schema.Schema.Type; diff --git a/packages/hypergraph/src/store.ts b/packages/hypergraph/src/store.ts index 45fddbf0..1faa514c 100644 --- a/packages/hypergraph/src/store.ts +++ b/packages/hypergraph/src/store.ts @@ -2,17 +2,53 @@ import type { AnyDocumentId, DocHandle } from '@automerge/automerge-repo'; import { Repo } from '@automerge/automerge-repo'; import { type Store, createStore } from '@xstate/store'; import type { Address } from 'viem'; +import { mergeMessages } from './inboxes/merge-messages.js'; +import type { InboxSenderAuthPolicy } from './inboxes/types.js'; import type { Identity } from './index.js'; import type { Invitation, Updates } from './messages/index.js'; import type { SpaceEvent, SpaceState } from './space-events/index.js'; import { idToAutomergeId } from './utils/automergeId.js'; +export type InboxMessageStorageEntry = { + id: string; + plaintext: string; + ciphertext: string; + signature: { + hex: string; + recovery: number; + } | null; + createdAt: string; + authorAccountId: string | null; +}; + +export type SpaceInboxStorageEntry = { + inboxId: string; + isPublic: boolean; + authPolicy: InboxSenderAuthPolicy; + encryptionPublicKey: string; + secretKey: string; + lastMessageClock: string; + messages: InboxMessageStorageEntry[]; // Kept sorted by UUIDv7 + seenMessageIds: Set; // For deduplication +}; + +export type AccountInboxStorageEntry = { + inboxId: string; + isPublic: boolean; + authPolicy: InboxSenderAuthPolicy; + encryptionPublicKey: string; + lastMessageClock: string; + messages: InboxMessageStorageEntry[]; // Kept sorted by UUIDv7 + seenMessageIds: Set; // For deduplication +}; + export type SpaceStorageEntry = { id: string; events: SpaceEvent[]; state: SpaceState | undefined; keys: { id: string; key: string }[]; automergeDocHandle: DocHandle | undefined; + inboxes: SpaceInboxStorageEntry[]; }; interface StoreContext { @@ -33,6 +69,7 @@ interface StoreContext { sessionToken: string | null; keys: Identity.IdentityKeys | null; lastUpdateClock: { [spaceId: string]: number }; + accountInboxes: AccountInboxStorageEntry[]; } const initialStoreContext: StoreContext = { @@ -46,6 +83,7 @@ const initialStoreContext: StoreContext = { sessionToken: null, keys: null, lastUpdateClock: {}, + accountInboxes: [], }; type StoreEvent = @@ -65,11 +103,34 @@ type StoreEvent = accountProof: string; keyProof: string; } + | { + type: 'setSpaceInbox'; + spaceId: string; + inbox: SpaceInboxStorageEntry; + } + | { + type: 'setSpaceInboxMessages'; + spaceId: string; + inboxId: string; + messages: InboxMessageStorageEntry[]; + lastMessageClock: string; + } + | { + type: 'setAccountInbox'; + inbox: AccountInboxStorageEntry; + } + | { + type: 'setAccountInboxMessages'; + inboxId: string; + messages: InboxMessageStorageEntry[]; + lastMessageClock: string; + } | { type: 'setSpace'; spaceId: string; updates?: Updates; events: SpaceEvent[]; + inboxes?: SpaceInboxStorageEntry[]; spaceState: SpaceState; keys: { id: string; @@ -131,6 +192,7 @@ export const store: Store = create state: existingSpace.state, keys: existingSpace.keys ?? [], automergeDocHandle, + inboxes: existingSpace.inboxes ?? [], }; return newSpace; } @@ -151,6 +213,7 @@ export const store: Store = create events: [], state: undefined, keys: [], + inboxes: [], updates: [], lastUpdateClock: -1, automergeDocHandle, @@ -216,11 +279,127 @@ export const store: Store = create }, }; }, + setSpaceInbox: (context, event: { spaceId: string; inbox: SpaceInboxStorageEntry }) => { + return { + ...context, + spaces: context.spaces.map((space) => { + if (space.id === event.spaceId) { + const existingInbox = space.inboxes.find((inbox) => inbox.inboxId === event.inbox.inboxId); + if (existingInbox) { + return { + ...space, + inboxes: space.inboxes.map((inbox) => { + if (inbox.inboxId === event.inbox.inboxId) { + const { messages, seenMessageIds } = mergeMessages( + existingInbox.messages, + existingInbox.seenMessageIds, + event.inbox.messages, + ); + return { + ...event.inbox, + messages, + seenMessageIds, + }; + } + return inbox; + }), + }; + } + return { ...space, inboxes: [...space.inboxes, event.inbox] }; + } + return space; + }), + }; + }, + setSpaceInboxMessages: ( + context, + event: { spaceId: string; inboxId: string; messages: InboxMessageStorageEntry[]; lastMessageClock: string }, + ) => { + return { + ...context, + spaces: context.spaces.map((space) => { + if (space.id === event.spaceId) { + return { + ...space, + inboxes: space.inboxes.map((inbox) => { + if (inbox.inboxId === event.inboxId) { + const { messages, seenMessageIds } = mergeMessages( + inbox.messages, + inbox.seenMessageIds, + event.messages, + ); + return { + ...inbox, + messages, + seenMessageIds, + lastMessageClock: new Date( + Math.max(new Date(inbox.lastMessageClock).getTime(), new Date(event.lastMessageClock).getTime()), + ).toISOString(), + }; + } + return inbox; + }), + }; + } + return space; + }), + }; + }, + setAccountInbox: (context, event: { inbox: AccountInboxStorageEntry }) => { + const existingInbox = context.accountInboxes.find((inbox) => inbox.inboxId === event.inbox.inboxId); + if (existingInbox) { + return { + ...context, + accountInboxes: context.accountInboxes.map((inbox) => { + if (inbox.inboxId === event.inbox.inboxId) { + const { messages, seenMessageIds } = mergeMessages( + existingInbox.messages, + existingInbox.seenMessageIds, + event.inbox.messages, + ); + return { + ...event.inbox, + messages, + seenMessageIds, + }; + } + return inbox; + }), + }; + } + return { + ...context, + accountInboxes: [...context.accountInboxes, event.inbox], + }; + }, + setAccountInboxMessages: ( + context, + event: { inboxId: string; messages: InboxMessageStorageEntry[]; lastMessageClock: string }, + ) => { + return { + ...context, + accountInboxes: context.accountInboxes.map((inbox) => { + if (inbox.inboxId === event.inboxId) { + const { messages, seenMessageIds } = mergeMessages(inbox.messages, inbox.seenMessageIds, event.messages); + return { + ...inbox, + messages, + seenMessageIds, + lastMessageClock: new Date( + Math.max(new Date(inbox.lastMessageClock).getTime(), new Date(event.lastMessageClock).getTime()), + ).toISOString(), + }; + } + return inbox; + }), + }; + }, setSpace: ( context, event: { spaceId: string; updates?: Updates; + inboxes?: SpaceInboxStorageEntry[]; events: SpaceEvent[]; spaceState: SpaceState; keys: { @@ -241,6 +420,7 @@ export const store: Store = create state: event.spaceState, keys: event.keys, automergeDocHandle, + inboxes: event.inboxes ?? [], }; return { ...context, @@ -263,11 +443,22 @@ export const store: Store = create ...context, spaces: context.spaces.map((space) => { if (space.id === event.spaceId) { + // Merge inboxes: keep existing ones and add new ones + const mergedInboxes = [...space.inboxes]; + for (const newInbox of event.inboxes ?? []) { + const existingInboxIndex = mergedInboxes.findIndex((inbox) => inbox.inboxId === newInbox.inboxId); + if (existingInboxIndex === -1) { + // Only add if it's a new inbox + mergedInboxes.push(newInbox); + } + } + return { ...space, events: event.events, state: event.spaceState, keys: event.keys, + inboxes: mergedInboxes, }; } return space; diff --git a/packages/hypergraph/src/utils/stringToUint8Array.ts b/packages/hypergraph/src/utils/stringToUint8Array.ts index a3340dd6..e8a4596e 100644 --- a/packages/hypergraph/src/utils/stringToUint8Array.ts +++ b/packages/hypergraph/src/utils/stringToUint8Array.ts @@ -1,5 +1,9 @@ const encoder = new TextEncoder(); - +const decoder = new TextDecoder(); export const stringToUint8Array = (str: string): Uint8Array => { return encoder.encode(str); }; + +export const uint8ArrayToString = (uint8Array: Uint8Array): string => { + return decoder.decode(uint8Array); +}; diff --git a/packages/hypergraph/test/inboxes/inboxes.test.ts b/packages/hypergraph/test/inboxes/inboxes.test.ts new file mode 100644 index 00000000..02c6fd5f --- /dev/null +++ b/packages/hypergraph/test/inboxes/inboxes.test.ts @@ -0,0 +1,904 @@ +import { x25519 } from '@noble/curves/ed25519'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { v4 as uuidv4 } from 'uuid'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Identity from '../../src/identity'; +import { + createAccountInboxCreationMessage, + createSpaceInboxCreationMessage, + decryptInboxMessage, + encryptInboxMessage, + prepareAccountInboxMessage, + prepareSpaceInboxMessage, + recoverAccountInboxCreatorKey, + recoverAccountInboxMessageSigner, + recoverSpaceInboxCreatorKey, + recoverSpaceInboxMessageSigner, + validateAccountInboxMessage, + validateSpaceInboxMessage, +} from '../../src/inboxes'; +import { mergeMessages } from '../../src/inboxes'; +import * as Messages from '../../src/messages'; +import type { AccountInboxStorageEntry, InboxMessageStorageEntry, SpaceInboxStorageEntry } from '../../src/store'; +import { bytesToHex, canonicalize, generateId, hexToBytes, stringToUint8Array } from '../../src/utils'; + +describe('inboxes', () => { + // Create real private keys for testing + const signaturePrivateKey = secp256k1.utils.randomPrivateKey(); + const signaturePublicKey = secp256k1.getPublicKey(signaturePrivateKey, true); + const encryptionPrivateKey = secp256k1.utils.randomPrivateKey(); + const encryptionPublicKey = secp256k1.getPublicKey(encryptionPrivateKey, true); + const spaceSecretKey = secp256k1.utils.randomPrivateKey(); + + const messageEncryptionPrivateKeyUint8Array = x25519.utils.randomPrivateKey(); + const messageEncryptionPublicKeyUint8Array = x25519.getPublicKey(messageEncryptionPrivateKeyUint8Array); + const messageEncryptionPrivateKey = bytesToHex(messageEncryptionPrivateKeyUint8Array); + const messageEncryptionPublicKey = bytesToHex(messageEncryptionPublicKeyUint8Array); + + const testParams = { + accountId: '0x1234567890123456789012345678901234567890', // 40-char ethereum address + isPublic: true, + spaceId: generateId(), + authPolicy: 'requires_auth', + }; + + describe('createAccountInboxCreationMessage', () => { + it('should create a valid account inbox creation message', () => { + const result = createAccountInboxCreationMessage({ + ...testParams, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + + expect(result.type).toBe('create-account-inbox'); + expect(result.accountId).toBe(testParams.accountId); + expect(result.isPublic).toBe(testParams.isPublic); + expect(result.authPolicy).toBe(testParams.authPolicy); + expect(result.encryptionPublicKey).toBe(bytesToHex(encryptionPublicKey)); + + // Verify inboxId is a 32-byte hex string + expect(result.inboxId).toMatch(/^0x[0-9a-f]{64}$/i); + + // Verify signature exists and has correct format + expect(result.signature).toHaveProperty('hex'); + expect(result.signature).toHaveProperty('recovery'); + }); + + it('should generate unique inbox IDs for each call', () => { + const result1 = createAccountInboxCreationMessage({ + ...testParams, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + const result2 = createAccountInboxCreationMessage({ + ...testParams, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + + expect(result1.inboxId).not.toBe(result2.inboxId); + }); + + it('should create valid signatures that can be verified', () => { + const result = createAccountInboxCreationMessage({ + ...testParams, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + + // Reconstruct the message that was signed + const messageToVerify = stringToUint8Array( + canonicalize({ + accountId: testParams.accountId, + inboxId: result.inboxId, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + }), + ); + + // Verify the signature + const sig = secp256k1.Signature.fromCompact(result.signature.hex).addRecoveryBit(result.signature.recovery); + + const isValid = secp256k1.verify(sig, sha256(messageToVerify), signaturePublicKey); + + expect(isValid).toBe(true); + }); + + it('should work with different auth policies', () => { + const policies = ['anonymous', 'optional_auth', 'requires_auth']; + + for (const policy of policies) { + const result = createAccountInboxCreationMessage({ + ...testParams, + authPolicy: policy, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + expect(result.authPolicy).toBe(policy); + } + }); + + it('should work with both public and private inboxes', () => { + const publicInbox = createAccountInboxCreationMessage({ + ...testParams, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + expect(publicInbox.isPublic).toBe(true); + + const privateInbox = createAccountInboxCreationMessage({ + ...testParams, + isPublic: false, + encryptionPublicKey: bytesToHex(encryptionPublicKey), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + expect(privateInbox.isPublic).toBe(false); + }); + }); + + describe('createSpaceInboxCreationMessage', () => { + it('should create a valid space inbox creation message', async () => { + const result = await createSpaceInboxCreationMessage({ + author: { + accountId: testParams.accountId, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + }, + authPolicy: testParams.authPolicy, + spaceId: testParams.spaceId, + isPublic: testParams.isPublic, + spaceSecretKey: bytesToHex(spaceSecretKey), + previousEventHash: generateId(), + }); + + expect(result.type).toBe('create-space-inbox-event'); + expect(result.spaceId).toBe(testParams.spaceId); + expect(result.event).toBeDefined(); + expect(result.event.transaction.spaceId).toBe(testParams.spaceId); + + // Verify the event contains required fields + expect(result.event.transaction.inboxId).toMatch(/^0x[0-9a-f]{64}$/i); + expect(result.event.transaction.encryptionPublicKey).toBeDefined(); + expect(result.event.transaction.secretKey).toBeDefined(); + expect(result.event.transaction.isPublic).toBe(testParams.isPublic); + expect(result.event.transaction.authPolicy).toBe(testParams.authPolicy); + }); + + it('should encrypt the inbox secret key', async () => { + const result = await createSpaceInboxCreationMessage({ + author: { + accountId: testParams.accountId, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + }, + authPolicy: testParams.authPolicy, + spaceId: testParams.spaceId, + isPublic: testParams.isPublic, + spaceSecretKey: bytesToHex(spaceSecretKey), + previousEventHash: generateId(), + }); + + // Decrypt the secret key + const decryptedSecretKey = Messages.decryptMessage({ + nonceAndCiphertext: hexToBytes(result.event.transaction.secretKey), + secretKey: spaceSecretKey, + }); + // The decrypted secret key should match the public key of the inbox + const inboxPublicKey = x25519.getPublicKey(decryptedSecretKey); + expect(bytesToHex(inboxPublicKey)).toBe(result.event.transaction.encryptionPublicKey); + }); + }); + + describe('recover inbox creator key', () => { + describe('recoverAccountInboxCreatorKey', () => { + it('should recover the creator key', () => { + const inbox = createAccountInboxCreationMessage({ + accountId: '0x1234567890123456789012345678901234567890', + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: bytesToHex(secp256k1.getPublicKey(encryptionPrivateKey, true)), + signaturePrivateKey: bytesToHex(signaturePrivateKey), + }); + const creatorKey = recoverAccountInboxCreatorKey(inbox); + expect(creatorKey).toBe(bytesToHex(secp256k1.getPublicKey(signaturePrivateKey, true))); + }); + }); + describe('recoverSpaceInboxCreatorKey', () => { + it('should recover the creator key', async () => { + const inbox = await createSpaceInboxCreationMessage({ + author: { + accountId: '0x1234567890123456789012345678901234567890', + signaturePrivateKey: bytesToHex(signaturePrivateKey), + encryptionPublicKey: bytesToHex(secp256k1.getPublicKey(encryptionPrivateKey, true)), + }, + spaceId: generateId(), + isPublic: true, + authPolicy: 'requires_auth', + spaceSecretKey: bytesToHex(spaceSecretKey), + previousEventHash: generateId(), + }); + const creatorKey = recoverSpaceInboxCreatorKey(inbox.event); + expect(creatorKey).toBe(bytesToHex(secp256k1.getPublicKey(signaturePrivateKey, true))); + }); + }); + }); + + describe('inbox message encryption', () => { + it('should encrypt and decrypt a message successfully', () => { + const originalMessage = 'Hello, this is a secret message!'; + + // Encrypt the message + const encrypted = encryptInboxMessage({ + message: originalMessage, + encryptionPublicKey: messageEncryptionPublicKey, + }); + + // Verify the encrypted result has the expected properties + expect(encrypted.ciphertext).toMatch(/^0x[0-9a-f]+$/i); + + // Decrypt the message + const decrypted = decryptInboxMessage({ + ciphertext: encrypted.ciphertext, + encryptionPrivateKey: messageEncryptionPrivateKey, + encryptionPublicKey: messageEncryptionPublicKey, + }); + + // Verify the decrypted message matches the original + expect(decrypted).toBe(originalMessage); + }); + + it('should fail to decrypt with wrong private key', () => { + const originalMessage = 'Hello, this is a secret message!'; + const wrongPrivateKey = bytesToHex(x25519.utils.randomPrivateKey()); + + const encrypted = encryptInboxMessage({ + message: originalMessage, + encryptionPublicKey: messageEncryptionPublicKey, + }); + + // Attempt to decrypt with wrong private key should throw + expect(() => + decryptInboxMessage({ + ciphertext: encrypted.ciphertext, + encryptionPrivateKey: wrongPrivateKey, + encryptionPublicKey: messageEncryptionPublicKey, + }), + ).toThrow(); + }); + }); + + describe('prepare inbox message', () => { + it('should prepare (encrypt and sign) a space inbox message', async () => { + const message = 'Hello, this is a secret message!'; + const spaceId = generateId(); + const inboxId = generateId(); + const messageToSend = await prepareSpaceInboxMessage({ + message, + spaceId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: messageEncryptionPrivateKey, + authorAccountId: '0x1234567890123456789012345678901234567890', + }); + expect(messageToSend.ciphertext).toMatch(/^0x[0-9a-f]+$/i); + expect(messageToSend.signature).toBeDefined(); + expect(messageToSend.signature?.hex).toMatch(/^[0-9a-f]+$/i); + expect(messageToSend.signature?.recovery).toBeDefined(); + expect(messageToSend.authorAccountId).toBe('0x1234567890123456789012345678901234567890'); + }); + it('should prepare (encrypt and sign) an account inbox message', async () => { + const message = 'Hello, this is a secret message!'; + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + const messageToSend = await prepareAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: messageEncryptionPrivateKey, + authorAccountId: '0xabcde567890123456789012345678901234567890', + }); + expect(messageToSend.ciphertext).toMatch(/^0x[0-9a-f]+$/i); + expect(messageToSend.signature).toBeDefined(); + expect(messageToSend.signature?.hex).toMatch(/^[0-9a-f]+$/i); + expect(messageToSend.signature?.recovery).toBeDefined(); + expect(messageToSend.authorAccountId).toBe('0xabcde567890123456789012345678901234567890'); + }); + }); + + describe('recovering inbox message signers', () => { + it('should recover the signer of a space inbox message', async () => { + const message = 'Hello, this is a secret message!'; + const spaceId = generateId(); + const inboxId = generateId(); + const messageToSend = await prepareSpaceInboxMessage({ + message, + spaceId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: '0x1234567890123456789012345678901234567890', + }); + const signer = recoverSpaceInboxMessageSigner(messageToSend, spaceId, inboxId); + expect(signer).toBe(bytesToHex(secp256k1.getPublicKey(signaturePrivateKey, true))); + }); + it('should recover the signer of an account inbox message', async () => { + const message = 'Hello, this is a secret message!'; + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + const messageToSend = await prepareAccountInboxMessage({ + message, + accountId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: '0xabcde567890123456789012345678901234567890', + }); + const signer = recoverAccountInboxMessageSigner(messageToSend, accountId, inboxId); + expect(signer).toBe(bytesToHex(signaturePublicKey)); + }); + }); + + describe('space inboxmessage validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + vi.spyOn(Identity, 'getVerifiedIdentity').mockImplementation(async (accountId: string) => ({ + accountId, + signaturePublicKey: bytesToHex(signaturePublicKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + })); + + it('should validate a properly signed space inbox message', async () => { + const spaceId = generateId(); + const inboxId = generateId(); + + const message = await prepareSpaceInboxMessage({ + message: 'test message', + spaceId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: SpaceInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateSpaceInboxMessage(message, inbox, spaceId, 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).toHaveBeenCalledWith(testParams.accountId, 'https://sync.example.com'); + }); + + it('should reject unsigned messages for RequiresAuth inboxes', async () => { + const message = { + ciphertext: '0x123', + } as Messages.InboxMessage; + + const inbox: SpaceInboxStorageEntry = { + inboxId: generateId(), + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateSpaceInboxMessage(message, inbox, generateId(), 'https://sync.example.com'); + + expect(isValid).toBe(false); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + + it('should accept unsigned messages for Anonymous inboxes', async () => { + const message = { + ciphertext: '0x123', + } as Messages.InboxMessage; + + const inbox: SpaceInboxStorageEntry = { + inboxId: generateId(), + isPublic: true, + authPolicy: 'anonymous', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateSpaceInboxMessage(message, inbox, generateId(), 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + + it('should reject signed messages for Anonymous inboxes', async () => { + const spaceId = generateId(); + const inboxId = generateId(); + + const message = await prepareSpaceInboxMessage({ + message: 'test message', + spaceId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: SpaceInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'anonymous', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateSpaceInboxMessage(message, inbox, spaceId, 'https://sync.example.com'); + + expect(isValid).toBe(false); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + + it('should handle identity verification failures', async () => { + const spaceId = generateId(); + const inboxId = generateId(); + + // Mock identity verification failure + vi.spyOn(Identity, 'getVerifiedIdentity').mockRejectedValueOnce(new Error('Failed to verify identity')); + + const message = await prepareSpaceInboxMessage({ + message: 'test message', + spaceId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: SpaceInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + await expect(validateSpaceInboxMessage(message, inbox, spaceId, 'https://sync.example.com')).rejects.toThrow( + 'Failed to verify identity', + ); + }); + + it('should accept signed messages on inboxes with optional auth', async () => { + const spaceId = generateId(); + const inboxId = generateId(); + + const message = await prepareSpaceInboxMessage({ + message: 'test message', + spaceId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: SpaceInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'optional_auth', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateSpaceInboxMessage(message, inbox, spaceId, 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).toHaveBeenCalledWith(testParams.accountId, 'https://sync.example.com'); + }); + + it('should accept unsigned messages on inboxes with optional auth', async () => { + const message = { + ciphertext: '0x123', + } as Messages.InboxMessage; + + const inbox: SpaceInboxStorageEntry = { + inboxId: generateId(), + isPublic: true, + authPolicy: 'optional_auth', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateSpaceInboxMessage(message, inbox, generateId(), 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + + it('should reject messages with mismatched signature and authorAccountId', async () => { + const spaceId = generateId(); + const inboxId = generateId(); + + // Create a different key pair for the "wrong" signer + const differentSignaturePrivateKey = secp256k1.utils.randomPrivateKey(); + const differentSignaturePublicKey = secp256k1.getPublicKey(differentSignaturePrivateKey, true); + const differentAccountId = '0x2222222222222222222222222222222222222222'; + + // Mock to return the correct public key for each account + vi.spyOn(Identity, 'getVerifiedIdentity').mockImplementation(async (accountId: string) => { + if (accountId === differentAccountId) { + return { + accountId, + signaturePublicKey: bytesToHex(differentSignaturePublicKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + }; + } + return { + accountId, + signaturePublicKey: bytesToHex(signaturePublicKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + }; + }); + + // Create message signed by the different key but claiming to be from the original account + const message = await prepareSpaceInboxMessage({ + message: 'test message', + spaceId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(differentSignaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: SpaceInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: messageEncryptionPublicKey, + secretKey: messageEncryptionPrivateKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateSpaceInboxMessage(message, inbox, spaceId, 'https://sync.example.com'); + + expect(isValid).toBe(false); + expect(Identity.getVerifiedIdentity).toHaveBeenCalledWith(testParams.accountId, 'https://sync.example.com'); + }); + }); + + describe('account inbox message validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(Identity, 'getVerifiedIdentity').mockImplementation(async (accountId: string) => ({ + accountId, + signaturePublicKey: bytesToHex(signaturePublicKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + })); + }); + + it('should validate a properly signed account inbox message', async () => { + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + + const message = await prepareAccountInboxMessage({ + message: 'test message', + accountId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: AccountInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: messageEncryptionPublicKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateAccountInboxMessage(message, inbox, accountId, 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).toHaveBeenCalledWith(testParams.accountId, 'https://sync.example.com'); + }); + + it('should reject unsigned messages for RequiresAuth inboxes', async () => { + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + + const message = { + ciphertext: '0x123', + } as Messages.InboxMessage; + + const inbox: AccountInboxStorageEntry = { + inboxId: generateId(), + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: messageEncryptionPublicKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateAccountInboxMessage(message, inbox, accountId, 'https://sync.example.com'); + + expect(isValid).toBe(false); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + + it('should reject messages with mismatched signature and authorAccountId', async () => { + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + + // Create a different key pair for the "wrong" signer + const differentSignaturePrivateKey = secp256k1.utils.randomPrivateKey(); + const differentSignaturePublicKey = secp256k1.getPublicKey(differentSignaturePrivateKey, true); + const differentAccountId = '0x2222222222222222222222222222222222222222'; + + // Mock to return the correct public key for each account + vi.spyOn(Identity, 'getVerifiedIdentity').mockImplementation(async (accountId: string) => { + if (accountId === differentAccountId) { + return { + accountId, + signaturePublicKey: bytesToHex(differentSignaturePublicKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + }; + } + return { + accountId, + signaturePublicKey: bytesToHex(signaturePublicKey), + encryptionPublicKey: bytesToHex(encryptionPublicKey), + }; + }); + + // Create message signed by the different key but claiming to be from the original account + const message = await prepareAccountInboxMessage({ + message: 'test message', + accountId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(differentSignaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: AccountInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'requires_auth', + encryptionPublicKey: messageEncryptionPublicKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateAccountInboxMessage(message, inbox, accountId, 'https://sync.example.com'); + + expect(isValid).toBe(false); + expect(Identity.getVerifiedIdentity).toHaveBeenCalledWith(testParams.accountId, 'https://sync.example.com'); + }); + + it('should accept unsigned messages for Anonymous inboxes', async () => { + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + + const message = { + ciphertext: '0x123', + } as Messages.InboxMessage; + + const inbox: AccountInboxStorageEntry = { + inboxId: generateId(), + isPublic: true, + authPolicy: 'anonymous', + encryptionPublicKey: messageEncryptionPublicKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateAccountInboxMessage(message, inbox, accountId, 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + + it('should reject signed messages for Anonymous inboxes', async () => { + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + + const message = await prepareAccountInboxMessage({ + message: 'test message', + accountId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: AccountInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'anonymous', + encryptionPublicKey: messageEncryptionPublicKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateAccountInboxMessage(message, inbox, accountId, 'https://sync.example.com'); + + expect(isValid).toBe(false); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + + it('should accept signed messages on inboxes with optional auth', async () => { + const accountId = '0x1234567890123456789012345678901234567890'; + const inboxId = generateId(); + + const message = await prepareAccountInboxMessage({ + message: 'test message', + accountId, + inboxId, + encryptionPublicKey: messageEncryptionPublicKey, + signaturePrivateKey: bytesToHex(signaturePrivateKey), + authorAccountId: testParams.accountId, + }); + + const inbox: AccountInboxStorageEntry = { + inboxId, + isPublic: true, + authPolicy: 'optional_auth', + encryptionPublicKey: messageEncryptionPublicKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateAccountInboxMessage(message, inbox, accountId, 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).toHaveBeenCalledWith(testParams.accountId, 'https://sync.example.com'); + }); + + it('should accept unsigned messages on inboxes with optional auth', async () => { + const accountId = '0x1234567890123456789012345678901234567890'; + + const message = { + ciphertext: '0x123', + } as Messages.InboxMessage; + + const inbox: AccountInboxStorageEntry = { + inboxId: generateId(), + isPublic: true, + authPolicy: 'optional_auth', + encryptionPublicKey: messageEncryptionPublicKey, + lastMessageClock: new Date(0).toISOString(), + messages: [], + seenMessageIds: new Set(), + }; + + const isValid = await validateAccountInboxMessage(message, inbox, accountId, 'https://sync.example.com'); + + expect(isValid).toBe(true); + expect(Identity.getVerifiedIdentity).not.toHaveBeenCalled(); + }); + }); + + describe('mergeMessages', () => { + const createMessage = (date: Date, plaintext = 'test'): InboxMessageStorageEntry => ({ + id: uuidv4(), + plaintext, + ciphertext: '0x123', + signature: null, + createdAt: date.toISOString(), + authorAccountId: null, + }); + + it('should merge new messages with existing messages', () => { + const existing = [createMessage(new Date('2023-01-01')), createMessage(new Date('2023-01-02'))]; + const existingSeenIds = new Set(existing.map((m) => m.id)); + const newMessages = [createMessage(new Date('2023-01-03')), createMessage(new Date('2023-01-04'))]; + + const result = mergeMessages(existing, existingSeenIds, newMessages); + + expect(result.messages).toHaveLength(4); + expect(result.messages.map((m) => m.createdAt)).toEqual([ + new Date('2023-01-01').toISOString(), + new Date('2023-01-02').toISOString(), + new Date('2023-01-03').toISOString(), + new Date('2023-01-04').toISOString(), + ]); + expect(result.seenMessageIds.size).toBe(4); + }); + + it('should deduplicate messages', () => { + const duplicateMessage = createMessage(new Date('2023-01-02')); + const existing = [createMessage(new Date('2023-01-01')), duplicateMessage]; + const existingSeenIds = new Set(existing.map((m) => m.id)); + const newMessages = [duplicateMessage, createMessage(new Date('2023-01-03'))]; + + const result = mergeMessages(existing, existingSeenIds, newMessages); + + expect(result.messages).toHaveLength(3); + expect(result.messages.map((m) => m.createdAt)).toEqual([ + new Date('2023-01-01').toISOString(), + new Date('2023-01-02').toISOString(), + new Date('2023-01-03').toISOString(), + ]); + expect(result.seenMessageIds.size).toBe(3); + }); + + it('should sort messages when new messages are older', () => { + const existing = [createMessage(new Date('2023-01-02')), createMessage(new Date('2023-01-03'))]; + const existingSeenIds = new Set(existing.map((m) => m.id)); + const newMessages = [ + createMessage(new Date('2023-01-01')), // older message + createMessage(new Date('2023-01-04')), + ]; + + const result = mergeMessages(existing, existingSeenIds, newMessages); + + expect(result.messages).toHaveLength(4); + expect(result.messages.map((m) => m.createdAt)).toEqual([ + new Date('2023-01-01').toISOString(), + new Date('2023-01-02').toISOString(), + new Date('2023-01-03').toISOString(), + new Date('2023-01-04').toISOString(), + ]); + }); + + it('should handle empty existing messages', () => { + const existing: InboxMessageStorageEntry[] = []; + const existingSeenIds = new Set(); + const newMessages = [createMessage(new Date('2023-01-01')), createMessage(new Date('2023-01-02'))]; + + const result = mergeMessages(existing, existingSeenIds, newMessages); + + expect(result.messages).toHaveLength(2); + expect(result.messages.map((m) => m.createdAt)).toEqual([ + new Date('2023-01-01').toISOString(), + new Date('2023-01-02').toISOString(), + ]); + }); + + it('should handle empty new messages', () => { + const existing = [createMessage(new Date('2023-01-01')), createMessage(new Date('2023-01-02'))]; + const existingSeenIds = new Set(existing.map((m) => m.id)); + const newMessages: InboxMessageStorageEntry[] = []; + + const result = mergeMessages(existing, existingSeenIds, newMessages); + + expect(result.messages).toHaveLength(2); + expect(result.messages).toEqual(existing); + expect(result.seenMessageIds).toEqual(existingSeenIds); + }); + }); +}); diff --git a/packages/hypergraph/test/key/key-box.test.ts b/packages/hypergraph/test/key/key-box.test.ts index d58cb379..0ff55169 100644 --- a/packages/hypergraph/test/key/key-box.test.ts +++ b/packages/hypergraph/test/key/key-box.test.ts @@ -37,7 +37,7 @@ describe('KeyBox Encryption/Decryption', () => { publicKey, secretKey, }), - ).toThrow('Nonce must be 24 bytes'); + ).toThrow('Uint8Array expected of length 24, got length=16'); }); it('should handle empty message', () => { @@ -117,7 +117,7 @@ describe('KeyBox Encryption/Decryption', () => { publicKey, secretKey, }), - ).toThrow('Nonce must be 24 bytes'); + ).toThrow('Uint8Array expected of length 24, got length=16'); }); it('should throw error for ciphertext too short', () => { @@ -132,7 +132,7 @@ describe('KeyBox Encryption/Decryption', () => { publicKey, secretKey, }), - ).toThrow('Ciphertext too short'); + ).toThrow('invalid ciphertext length: smaller than tagLength=16'); }); it('should fail to decrypt with wrong keys', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f71ad73..aa4fe371 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: '@noble/secp256k1': specifier: ^2.2.3 version: 2.2.3 + '@serenity-kit/noble-sodium': + specifier: ^0.2.1 + version: 0.2.1 '@xstate/store': specifier: ^2.6.2 version: 2.6.2(react@19.0.0) @@ -1259,6 +1262,10 @@ packages: resolution: {integrity: sha512-YGdEUzYEd+82jeaVbSKKVp1jFZb8LwaNMIIzHFkihGvYdd/KKAr7KaJHdEdSYGredE3ssSravXIa0Jxg28Sv5w==} engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} @@ -1273,6 +1280,10 @@ packages: resolution: {integrity: sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.0': + resolution: {integrity: sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.3.2': resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} engines: {node: '>= 16'} @@ -1293,6 +1304,10 @@ packages: resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/secp256k1@2.2.3': resolution: {integrity: sha512-l7r5oEQym9Us7EAigzg30/PQAvynhMt2uoYtT3t26eGDVm9Yii5mZ5jWSWmZ/oSIR2Et0xfc6DXrG0bZ787V3w==} @@ -1677,6 +1692,9 @@ packages: '@scure/bip39@1.5.0': resolution: {integrity: sha512-Dop+ASYhnrwm9+HA/HwXg7j2ZqM6yk2fyLWb5znexjctFY3+E+eU8cIWI0Pql0Qx4hPZCijlGq4OL71g+Uz30A==} + '@serenity-kit/noble-sodium@0.2.1': + resolution: {integrity: sha512-023EjSl/ZMl8yNmnzeeWJh/V44QyBC82I8xuHltITeWdcyrQHbGnmMZRZOm/uTRinhgqoMzRBNQqbrfyuI5idg==} + '@simplewebauthn/browser@9.0.1': resolution: {integrity: sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==} @@ -6427,6 +6445,8 @@ snapshots: '@noble/ciphers@1.2.0': {} + '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': dependencies: '@noble/hashes': 1.3.2 @@ -6443,6 +6463,10 @@ snapshots: dependencies: '@noble/hashes': 1.7.0 + '@noble/curves@1.9.0': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/hashes@1.3.2': {} '@noble/hashes@1.4.0': {} @@ -6453,6 +6477,8 @@ snapshots: '@noble/hashes@1.7.0': {} + '@noble/hashes@1.8.0': {} + '@noble/secp256k1@2.2.3': {} '@nodelib/fs.scandir@2.1.5': @@ -6879,6 +6905,12 @@ snapshots: '@noble/hashes': 1.6.1 '@scure/base': 1.2.1 + '@serenity-kit/noble-sodium@0.2.1': + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.0 + '@noble/hashes': 1.8.0 + '@simplewebauthn/browser@9.0.1': dependencies: '@simplewebauthn/types': 9.0.1 @@ -6908,7 +6940,7 @@ snapshots: '@solana/wallet-standard-util@1.1.2': dependencies: - '@noble/curves': 1.8.0 + '@noble/curves': 1.9.0 '@solana/wallet-standard-chains': 1.1.1 '@solana/wallet-standard-features': 1.3.0 @@ -6939,8 +6971,8 @@ snapshots: '@solana/web3.js@1.95.3(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10)': dependencies: '@babel/runtime': 7.26.0 - '@noble/curves': 1.8.0 - '@noble/hashes': 1.7.0 + '@noble/curves': 1.9.0 + '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 agentkeepalive: 4.6.0 bigint-buffer: 1.1.5