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 (
+
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 (
+ Loading inbox messages...
;
+ }
+
+ if (error) {
+ return Loading...
;
+ }
+
+ if (error) {
+ return
- {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