diff --git a/apps/events/src/Boot.tsx b/apps/events/src/Boot.tsx
index 3ad5008f..70149a8c 100644
--- a/apps/events/src/Boot.tsx
+++ b/apps/events/src/Boot.tsx
@@ -1,6 +1,6 @@
import { RouterProvider, createRouter } from '@tanstack/react-router';
-import { Auth } from '@graphprotocol/hypergraph-react';
+import { Hypergraph } from '@graphprotocol/hypergraph-react';
import { PrivyProvider } from '@privy-io/react-auth';
import { routeTree } from './routeTree.gen';
@@ -33,9 +33,9 @@ export function Boot() {
},
}}
>
-
+
-
+
);
}
diff --git a/apps/events/src/components/logout.tsx b/apps/events/src/components/logout.tsx
index ae99657f..e23566c3 100644
--- a/apps/events/src/components/logout.tsx
+++ b/apps/events/src/components/logout.tsx
@@ -1,10 +1,10 @@
-import { Auth } from '@graphprotocol/hypergraph-react';
+import { Hypergraph } from '@graphprotocol/hypergraph-react';
import { usePrivy } from '@privy-io/react-auth';
import { useRouter } from '@tanstack/react-router';
import { Button } from './ui/button';
export function Logout() {
- const { logout: graphLogout } = Auth.useHypergraphAuth();
+ const { logout: graphLogout } = Hypergraph.useHypergraphApp();
const { logout: privyLogout } = usePrivy();
const router = useRouter();
const disconnectWallet = async () => {
diff --git a/apps/events/src/routes/__root.tsx b/apps/events/src/routes/__root.tsx
index 2d8a95ce..48647d8b 100644
--- a/apps/events/src/routes/__root.tsx
+++ b/apps/events/src/routes/__root.tsx
@@ -1,14 +1,13 @@
import { Logout } from '@/components/logout';
-import { Auth, Hypergraph } from '@graphprotocol/hypergraph-react';
+import { Hypergraph } from '@graphprotocol/hypergraph-react';
import { Link, Outlet, createRootRoute, useLayoutEffect, useRouter } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
export const Route = createRootRoute({
component: () => {
- const { authenticated, getIdentity, getSessionToken } = Auth.useHypergraphAuth();
+ const { authenticated, getIdentity } = Hypergraph.useHypergraphApp();
const graphIdentity = getIdentity();
- const loggedInSessionToken = getSessionToken();
const router = useRouter();
@@ -44,20 +43,7 @@ export const Route = createRootRoute({
- {authenticated && graphIdentity && loggedInSessionToken ? (
-
-
-
- ) : (
-
- )}
+
diff --git a/apps/events/src/routes/login.lazy.tsx b/apps/events/src/routes/login.lazy.tsx
index 4370d581..3a167b92 100644
--- a/apps/events/src/routes/login.lazy.tsx
+++ b/apps/events/src/routes/login.lazy.tsx
@@ -1,5 +1,5 @@
import type { Identity } from '@graphprotocol/hypergraph';
-import { Auth } from '@graphprotocol/hypergraph-react';
+import { Hypergraph } from '@graphprotocol/hypergraph-react';
import { usePrivy, useWallets } from '@privy-io/react-auth';
import { createLazyFileRoute, useRouter } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
@@ -17,7 +17,7 @@ export const Route = createLazyFileRoute('/login')({
function Login() {
const { ready: privyReady, login: privyLogin, signMessage, authenticated: privyAuthenticated } = usePrivy();
const { ready: walletsReady, wallets } = useWallets();
- const { setIdentityAndSessionToken, login: hypergraphLogin } = Auth.useHypergraphAuth();
+ const { setIdentityAndSessionToken, login: hypergraphLogin } = Hypergraph.useHypergraphApp();
const { navigate } = useRouter();
const [hypergraphLoginStarted, setHypergraphLoginStarted] = useState(false);
diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx
index 0fa50ad4..b5973df4 100644
--- a/packages/hypergraph-react/src/HypergraphAppContext.tsx
+++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx
@@ -7,13 +7,24 @@ import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, st
import { useSelector as useSelectorStore } from '@xstate/store/react';
import { Effect, Exit } from 'effect';
import * as Schema from 'effect/Schema';
-import { createContext, useContext, useEffect, useState } from 'react';
-import { useCallback } from 'react';
-import type { Address } from 'viem';
+import { type ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
+import { SiweMessage } from 'siwe';
+import type { Hex } from 'viem';
+import { type Address, getAddress } from 'viem';
+import { privateKeyToAccount } from 'viem/accounts';
const decodeResponseMessage = Schema.decodeUnknownEither(Messages.ResponseMessage);
export type HypergraphAppCtx = {
+ // auth related
+ getSessionToken(): string | null;
+ getAccountId(): string | null;
+ getIdentity(): Identity.Identity | null;
+ authenticated: boolean;
+ login(signer: Identity.Signer): Promise;
+ logout(): void;
+ setIdentityAndSessionToken(account: Identity.Identity & { sessionToken: string }): void;
+ // app related
invitations: Array;
createSpace(): Promise;
listSpaces(): void;
@@ -30,6 +41,19 @@ export type HypergraphAppCtx = {
};
export const HypergraphAppContext = createContext({
+ getAccountId() {
+ return null;
+ },
+ getIdentity() {
+ return null;
+ },
+ getSessionToken() {
+ return null;
+ },
+ authenticated: false,
+ async login() {},
+ logout() {},
+ setIdentityAndSessionToken() {},
invitations: [],
async createSpace() {
return {};
@@ -57,44 +81,320 @@ export function useHypergraphApp() {
return useContext(HypergraphAppContext);
}
+export function useAuthenticated() {
+ const ctx = useHypergraphApp();
+ return ctx.authenticated;
+}
+export function useHypergraphAccountId() {
+ const ctx = useHypergraphApp();
+ return ctx.getAccountId();
+}
+export function useHypergraphIdentity() {
+ const ctx = useHypergraphApp();
+ return ctx.getIdentity();
+}
+export function useHypergraphSessionToken() {
+ const ctx = useHypergraphApp();
+ return ctx.getSessionToken();
+}
+
export type HypergraphAppProviderProps = Readonly<{
- accountId: string;
- syncServer?: string;
- sessionToken?: string | null;
- encryptionPrivateKey?: string | null;
- encryptionPublicKey?: string | null;
- signaturePrivateKey?: string | null;
- signaturePublicKey?: string | null;
- children: React.ReactNode;
+ storage: Identity.Storage;
+ syncServerUri?: string;
+ chainId?: number;
+ children: ReactNode;
}>;
+// 1) a) Get session token from local storage, or
+// b) Auth with the sync server
+// 2) a)Try to get identity from the sync server, or
+// b) If identity is not found, create a new identity
+// (and store it in the sync server)
export function HypergraphAppProvider({
- accountId,
- syncServer = 'http://localhost:3030',
- sessionToken,
- encryptionPrivateKey,
- encryptionPublicKey,
- signaturePrivateKey,
- signaturePublicKey,
+ storage,
+ syncServerUri = 'http://localhost:3030',
+ chainId = 80451,
children,
}: HypergraphAppProviderProps) {
+ const [authState, setAuthState] = useState({
+ authenticated: false,
+ accountId: null,
+ sessionToken: null,
+ keys: null,
+ });
const [websocketConnection, setWebsocketConnection] = useState();
const [loading, setLoading] = useState(true);
const spaces = useSelectorStore(store, (state) => state.context.spaces);
const invitations = useSelectorStore(store, (state) => state.context.invitations);
const repo = useSelectorStore(store, (state) => state.context.repo);
- const syncServerUrl = new URL(syncServer);
- const syncServerWsUrl = new URL(`/?token=${sessionToken}`, syncServerUrl.toString());
- syncServerWsUrl.protocol = 'ws:';
- const syncServerWsUrlString = syncServerWsUrl.toString();
+ function prepareSiweMessage(address: Address, nonce: string) {
+ return new SiweMessage({
+ domain: window.location.host,
+ address,
+ statement: 'Sign in to Hypergraph',
+ uri: window.location.origin,
+ version: '1',
+ chainId,
+ nonce,
+ expirationTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
+ }).prepareMessage();
+ }
+
+ async function getSessionNonce(accountId: string) {
+ const nonceReq = { accountId } as const satisfies Messages.RequestLoginNonce;
+ const res = await fetch(new URL('/login/nonce', syncServerUri), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(nonceReq),
+ });
+ const decoded = Schema.decodeUnknownSync(Messages.ResponseLoginNonce)(await res.json());
+ return decoded.sessionNonce;
+ }
+
+ async function identityExists(accountId: string) {
+ const res = await fetch(new URL(`/identity?accountId=${accountId}`, syncServerUri), {
+ method: 'GET',
+ });
+ return res.status === 200;
+ }
+
+ async function loginWithWallet(signer: Identity.Signer, accountId: Address) {
+ const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
+ if (!sessionToken) {
+ const sessionNonce = await getSessionNonce(accountId);
+ // Use SIWE to login with the server and get a token
+ const message = prepareSiweMessage(accountId, sessionNonce);
+ const signature = await signer.signMessage(message);
+ const loginReq = { accountId, message, signature } as const satisfies Messages.RequestLogin;
+ const res = await fetch(new URL('/login', syncServerUri), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(loginReq),
+ });
+ const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json());
+ Identity.storeAccountId(storage, accountId);
+ Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
+ } else {
+ // use whoami to check if the session token is still valid
+ const res = await fetch(new URL('/whoami', syncServerUri), {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+ if (res.status !== 200 || (await res.text()) !== accountId) {
+ console.warn('Session token is invalid, wiping state and retrying login with wallet');
+ Identity.wipeSyncServerSessionToken(storage, accountId);
+ return await loginWithWallet(signer, accountId);
+ }
+ }
+ }
+
+ async function loginWithKeys(keys: Identity.IdentityKeys, accountId: Address) {
+ const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
+ if (sessionToken) {
+ // use whoami to check if the session token is still valid
+ const res = await fetch(new URL('/whoami', syncServerUri), {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+ if (res.status !== 200 || (await res.text()) !== accountId) {
+ console.warn('Session token is invalid, wiping state and retrying login with keys');
+ Identity.wipeSyncServerSessionToken(storage, accountId);
+ return await loginWithKeys(keys, accountId);
+ }
+ } else {
+ const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
+ const sessionNonce = await getSessionNonce(account.address);
+ const message = prepareSiweMessage(account.address, sessionNonce);
+ const signature = await account.signMessage({ message });
+ const req = {
+ accountId,
+ message,
+ publicKey: keys.signaturePublicKey,
+ signature,
+ } as const satisfies Messages.RequestLoginWithSigningKey;
+ const res = await fetch(new URL('/login/with-signing-key', syncServerUri), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(req),
+ });
+ if (res.status !== 200) {
+ throw new Error('Error logging in with signing key');
+ }
+ const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json());
+ Identity.storeAccountId(storage, accountId);
+ Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
+ }
+ }
+
+ async function restoreKeys(signer: Identity.Signer, accountId: Address) {
+ const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
+ if (!sessionToken) {
+ return;
+ }
+ const keys = Identity.loadKeys(storage, accountId);
+ if (!keys) {
+ // Try to get the users identity from the sync server
+ const res = await fetch(new URL('/identity/encrypted', syncServerUri), {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+ if (res.status === 200) {
+ console.log('Identity found');
+ const decoded = Schema.decodeUnknownSync(Messages.ResponseIdentityEncrypted)(await res.json());
+ const { keyBox } = decoded;
+ const { ciphertext, nonce } = keyBox;
+ const keys = await Identity.decryptIdentity(signer, accountId, ciphertext, nonce);
+ Identity.storeKeys(storage, accountId, keys);
+ } else {
+ throw new Error(`Error fetching identity ${res.status}`);
+ }
+ }
+ }
+
+ async function signup(signer: Identity.Signer, accountId: Address) {
+ const keys = Identity.createIdentityKeys();
+ const { ciphertext, nonce } = await Identity.encryptIdentity(signer, accountId, keys);
+ const { accountProof, keyProof } = await Identity.proveIdentityOwnership(signer, accountId, keys);
+
+ const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
+ const sessionNonce = await getSessionNonce(accountId);
+ const message = prepareSiweMessage(account.address, sessionNonce);
+ const signature = await account.signMessage({ message });
+ const req = {
+ keyBox: { accountId, ciphertext, nonce },
+ accountProof,
+ keyProof,
+ message,
+ signaturePublicKey: keys.signaturePublicKey,
+ encryptionPublicKey: keys.encryptionPublicKey,
+ signature,
+ } as const satisfies Messages.RequestCreateIdentity;
+ const res = await fetch(new URL('/identity', syncServerUri), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(req),
+ });
+ if (res.status !== 200) {
+ // TODO: handle this better?
+ throw new Error(`Error creating identity: ${res.status}`);
+ }
+ const decoded = Schema.decodeUnknownSync(Messages.ResponseCreateIdentity)(await res.json());
+ Identity.storeAccountId(storage, accountId);
+ Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
+ Identity.storeKeys(storage, accountId, keys);
+ }
+
+ async function login(signer: Identity.Signer) {
+ if (!signer) {
+ return;
+ }
+ const address = await signer.getAddress();
+ if (!address) {
+ return;
+ }
+ const accountId = getAddress(address);
+ const keys = Identity.loadKeys(storage, accountId);
+ if (!keys && !(await identityExists(accountId))) {
+ await signup(signer, accountId);
+ } else if (keys) {
+ await loginWithKeys(keys, accountId);
+ } else {
+ await loginWithWallet(signer, accountId).then(() => restoreKeys(signer, accountId));
+ }
+ console.log('Identity initialized');
+ setAuthState({
+ authenticated: true,
+ accountId,
+ sessionToken: Identity.loadSyncServerSessionToken(storage, accountId),
+ keys: Identity.loadKeys(storage, accountId),
+ });
+ store.send({ type: 'reset' });
+ }
+
+ function logout() {
+ websocketConnection?.close();
+ setWebsocketConnection(undefined);
+
+ const accountId = Identity.loadAccountId(storage) ?? authState.accountId;
+ Identity.wipeAccountId(storage);
+ if (!accountId) {
+ return;
+ }
+ Identity.wipeKeys(storage, accountId);
+ Identity.wipeSyncServerSessionToken(storage, accountId);
+ setAuthState({ authenticated: false, accountId: null, sessionToken: null, keys: null });
+ }
+
+ const setIdentityAndSessionToken = useCallback(
+ (account: Identity.Identity & { sessionToken: string }) => {
+ Identity.storeAccountId(storage, account.accountId);
+ Identity.storeSyncServerSessionToken(storage, account.accountId, account.sessionToken);
+ Identity.storeKeys(storage, account.accountId, {
+ encryptionPublicKey: account.encryptionPublicKey,
+ encryptionPrivateKey: account.encryptionPrivateKey,
+ signaturePublicKey: account.signaturePublicKey,
+ signaturePrivateKey: account.signaturePrivateKey,
+ });
+ store.send({ type: 'reset' });
+ setAuthState({
+ authenticated: true,
+ accountId: getAddress(account.accountId),
+ sessionToken: account.sessionToken,
+ keys: {
+ encryptionPublicKey: account.encryptionPublicKey,
+ encryptionPrivateKey: account.encryptionPrivateKey,
+ signaturePublicKey: account.signaturePublicKey,
+ signaturePrivateKey: account.signaturePrivateKey,
+ },
+ });
+ console.log('Identity set');
+ },
+ [storage],
+ );
+
+ // check if the user is already authenticated on initial render
+ const initialRenderAuthCheckRef = useRef(false);
+ useEffect(() => {
+ if (!initialRenderAuthCheckRef.current) {
+ const accountId = Identity.loadAccountId(storage);
+ if (accountId) {
+ const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
+ if (sessionToken) {
+ const keys = Identity.loadKeys(storage, accountId);
+ if (keys) {
+ // user is already authenticated, set state
+ setAuthState({ authenticated: true, accountId: getAddress(accountId), sessionToken, keys });
+ }
+ }
+ }
+ // set render auth check to true so next potential rerender doesn't proc this
+ initialRenderAuthCheckRef.current = true;
+ }
+ }, [storage]);
// Create a stable WebSocket connection that only depends on accountId
useEffect(() => {
- if (!sessionToken) {
- setLoading(false);
+ if (!authState.sessionToken) {
+ setLoading(true);
return;
}
+ const syncServerUrl = new URL(syncServerUri);
+ const syncServerWsUrl = new URL(`/?token=${authState.sessionToken}`, syncServerUrl.toString());
+ syncServerWsUrl.protocol = 'ws:';
+ const syncServerWsUrlString = syncServerWsUrl.toString();
+
const websocketConnection = new WebSocket(syncServerWsUrlString);
setWebsocketConnection(websocketConnection);
@@ -124,11 +424,16 @@ export function HypergraphAppProvider({
websocketConnection.removeEventListener('close', onClose);
websocketConnection.close();
};
- }, [sessionToken, syncServerWsUrlString]);
+ }, [authState.sessionToken, syncServerUri]);
// Handle WebSocket messages in a separate effect
useEffect(() => {
if (!websocketConnection) return;
+ const encryptionPrivateKey = authState.keys?.encryptionPrivateKey;
+ if (!encryptionPrivateKey) {
+ console.error('No encryption private key found');
+ return;
+ }
const onMessage = async (event: MessageEvent) => {
const data = Messages.deserialize(event.data);
@@ -146,10 +451,6 @@ export function HypergraphAppProvider({
break;
}
case 'space': {
- if (!encryptionPrivateKey) {
- console.error('No encryption private key found');
- return;
- }
let state: SpaceEvents.SpaceState | undefined = undefined;
for (const event of response.events) {
@@ -328,9 +629,17 @@ export function HypergraphAppProvider({
return () => {
websocketConnection.removeEventListener('message', onMessage);
};
- }, [websocketConnection, spaces, encryptionPrivateKey]);
+ }, [websocketConnection, spaces, authState.keys?.encryptionPrivateKey]);
const createSpaceForContext = async () => {
+ const accountId = authState.accountId;
+ if (!accountId) {
+ throw new Error('No account id found');
+ }
+ const encryptionPrivateKey = authState.keys?.encryptionPrivateKey;
+ const encryptionPublicKey = authState.keys?.encryptionPublicKey;
+ const signaturePrivateKey = authState.keys?.signaturePrivateKey;
+ const signaturePublicKey = authState.keys?.signaturePublicKey;
if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) {
throw new Error('Missing keys');
}
@@ -379,6 +688,14 @@ export function HypergraphAppProvider({
}: Readonly<{
invitation: Messages.Invitation;
}>) => {
+ const accountId = authState.accountId;
+ if (!accountId) {
+ throw new Error('No account id found');
+ }
+ const encryptionPrivateKey = authState.keys?.encryptionPrivateKey;
+ const encryptionPublicKey = authState.keys?.encryptionPublicKey;
+ const signaturePrivateKey = authState.keys?.signaturePrivateKey;
+ const signaturePublicKey = authState.keys?.signaturePublicKey;
if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) {
throw new Error('Missing keys');
}
@@ -435,7 +752,7 @@ export function HypergraphAppProvider({
signaturePublicKey: identity.signaturePublicKey,
};
}
- const res = await fetch(`${syncServer}/identity?accountId=${accountId}`);
+ const res = await fetch(`${syncServerUri}/identity?accountId=${accountId}`);
if (res.status !== 200) {
throw new Error('Failed to fetch identity');
}
@@ -476,6 +793,17 @@ export function HypergraphAppProvider({
accountId: string;
};
}>) => {
+ const accountId = authState.accountId;
+ if (!accountId) {
+ throw new Error('No account id found');
+ }
+ const encryptionPrivateKey = authState.keys?.encryptionPrivateKey;
+ const encryptionPublicKey = authState.keys?.encryptionPublicKey;
+ const signaturePrivateKey = authState.keys?.signaturePrivateKey;
+ const signaturePublicKey = authState.keys?.signaturePublicKey;
+ if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) {
+ throw new Error('Missing keys');
+ }
if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) {
throw new Error('Missing keys');
}
@@ -528,6 +856,28 @@ export function HypergraphAppProvider({
return (
);
}
+
+type HypergraphAppState = {
+ authenticated: boolean;
+ accountId: Address | null;
+ sessionToken: string | null;
+ keys: Identity.IdentityKeys | null;
+};
diff --git a/packages/hypergraph-react/src/HypergraphAuthContext.tsx b/packages/hypergraph-react/src/HypergraphAuthContext.tsx
deleted file mode 100644
index 149825e8..00000000
--- a/packages/hypergraph-react/src/HypergraphAuthContext.tsx
+++ /dev/null
@@ -1,372 +0,0 @@
-'use client';
-
-import { Identity, Messages, store } from '@graphprotocol/hypergraph';
-import * as Schema from 'effect/Schema';
-import { type ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
-import { SiweMessage } from 'siwe';
-import type { Hex } from 'viem';
-import { type Address, getAddress } from 'viem';
-import { privateKeyToAccount } from 'viem/accounts';
-
-export type HypergraphAuthCtx = {
- getSessionToken(): string | null;
- getAccountId(): string | null;
- getIdentity(): Identity.Identity | null;
- authenticated: boolean;
- login(signer: Identity.Signer): Promise;
- logout(): void;
- setIdentityAndSessionToken(account: Identity.Identity & { sessionToken: string }): void;
-};
-
-export const HypergraphAuthContext = createContext({
- getAccountId() {
- return null;
- },
- getIdentity() {
- return null;
- },
- getSessionToken() {
- return null;
- },
- authenticated: false,
- async login() {},
- logout() {},
- setIdentityAndSessionToken() {},
-});
-
-export function useHypergraphAuth() {
- return useContext(HypergraphAuthContext);
-}
-
-export function useAuthenticated() {
- const ctx = useHypergraphAuth();
- return ctx.authenticated;
-}
-export function useHypergraphAccountId() {
- const ctx = useHypergraphAuth();
- return ctx.getAccountId();
-}
-export function useHypergraphIdentity() {
- const ctx = useHypergraphAuth();
- return ctx.getIdentity();
-}
-export function useHypergraphSessionToken() {
- const ctx = useHypergraphAuth();
- return ctx.getSessionToken();
-}
-
-export type HypergraphAuthProviderProps = Readonly<{
- storage: Identity.Storage;
- syncServerUri?: string;
- chainId?: number;
- children: ReactNode;
-}>;
-// 1) a) Get session token from local storage, or
-// b) Auth with the sync server
-// 2) a)Try to get identity from the sync server, or
-// b) If identity is not found, create a new identity
-// (and store it in the sync server)
-export function HypergraphAuthProvider({
- storage,
- syncServerUri = 'http://localhost:3030',
- chainId = 80451,
- children,
-}: HypergraphAuthProviderProps) {
- const [authState, setAuthState] = useState({
- authenticated: false,
- accountId: null,
- sessionToken: null,
- keys: null,
- });
-
- function prepareSiweMessage(address: Address, nonce: string) {
- return new SiweMessage({
- domain: window.location.host,
- address,
- statement: 'Sign in to Hypergraph',
- uri: window.location.origin,
- version: '1',
- chainId,
- nonce,
- expirationTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
- }).prepareMessage();
- }
-
- async function getSessionNonce(accountId: string) {
- const nonceReq = { accountId } as const satisfies Messages.RequestLoginNonce;
- const res = await fetch(new URL('/login/nonce', syncServerUri), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(nonceReq),
- });
- const decoded = Schema.decodeUnknownSync(Messages.ResponseLoginNonce)(await res.json());
- return decoded.sessionNonce;
- }
-
- async function identityExists(accountId: string) {
- const res = await fetch(new URL(`/identity?accountId=${accountId}`, syncServerUri), {
- method: 'GET',
- });
- return res.status === 200;
- }
-
- async function loginWithWallet(signer: Identity.Signer, accountId: Address) {
- const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
- if (!sessionToken) {
- const sessionNonce = await getSessionNonce(accountId);
- // Use SIWE to login with the server and get a token
- const message = prepareSiweMessage(accountId, sessionNonce);
- const signature = await signer.signMessage(message);
- const loginReq = { accountId, message, signature } as const satisfies Messages.RequestLogin;
- const res = await fetch(new URL('/login', syncServerUri), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(loginReq),
- });
- const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json());
- Identity.storeAccountId(storage, accountId);
- Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
- } else {
- // use whoami to check if the session token is still valid
- const res = await fetch(new URL('/whoami', syncServerUri), {
- headers: {
- Authorization: `Bearer ${sessionToken}`,
- },
- });
- if (res.status !== 200 || (await res.text()) !== accountId) {
- console.warn('Session token is invalid, wiping state and retrying login with wallet');
- Identity.wipeSyncServerSessionToken(storage, accountId);
- return await loginWithWallet(signer, accountId);
- }
- }
- }
-
- async function loginWithKeys(keys: Identity.IdentityKeys, accountId: Address) {
- const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
- if (sessionToken) {
- // use whoami to check if the session token is still valid
- const res = await fetch(new URL('/whoami', syncServerUri), {
- headers: {
- Authorization: `Bearer ${sessionToken}`,
- },
- });
- if (res.status !== 200 || (await res.text()) !== accountId) {
- console.warn('Session token is invalid, wiping state and retrying login with keys');
- Identity.wipeSyncServerSessionToken(storage, accountId);
- return await loginWithKeys(keys, accountId);
- }
- } else {
- const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
- const sessionNonce = await getSessionNonce(account.address);
- const message = prepareSiweMessage(account.address, sessionNonce);
- const signature = await account.signMessage({ message });
- const req = {
- accountId,
- message,
- publicKey: keys.signaturePublicKey,
- signature,
- } as const satisfies Messages.RequestLoginWithSigningKey;
- const res = await fetch(new URL('/login/with-signing-key', syncServerUri), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(req),
- });
- if (res.status !== 200) {
- throw new Error('Error logging in with signing key');
- }
- const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json());
- Identity.storeAccountId(storage, accountId);
- Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
- }
- }
-
- async function restoreKeys(signer: Identity.Signer, accountId: Address) {
- const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
- if (!sessionToken) {
- return;
- }
- const keys = Identity.loadKeys(storage, accountId);
- if (!keys) {
- // Try to get the users identity from the sync server
- const res = await fetch(new URL('/identity/encrypted', syncServerUri), {
- headers: {
- Authorization: `Bearer ${sessionToken}`,
- },
- });
- if (res.status === 200) {
- console.log('Identity found');
- const decoded = Schema.decodeUnknownSync(Messages.ResponseIdentityEncrypted)(await res.json());
- const { keyBox } = decoded;
- const { ciphertext, nonce } = keyBox;
- const keys = await Identity.decryptIdentity(signer, accountId, ciphertext, nonce);
- Identity.storeKeys(storage, accountId, keys);
- } else {
- throw new Error(`Error fetching identity ${res.status}`);
- }
- }
- }
-
- async function signup(signer: Identity.Signer, accountId: Address) {
- const keys = Identity.createIdentityKeys();
- const { ciphertext, nonce } = await Identity.encryptIdentity(signer, accountId, keys);
- const { accountProof, keyProof } = await Identity.proveIdentityOwnership(signer, accountId, keys);
-
- const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
- const sessionNonce = await getSessionNonce(accountId);
- const message = prepareSiweMessage(account.address, sessionNonce);
- const signature = await account.signMessage({ message });
- const req = {
- keyBox: { accountId, ciphertext, nonce },
- accountProof,
- keyProof,
- message,
- signaturePublicKey: keys.signaturePublicKey,
- encryptionPublicKey: keys.encryptionPublicKey,
- signature,
- } as const satisfies Messages.RequestCreateIdentity;
- const res = await fetch(new URL('/identity', syncServerUri), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(req),
- });
- if (res.status !== 200) {
- // TODO: handle this better?
- throw new Error(`Error creating identity: ${res.status}`);
- }
- const decoded = Schema.decodeUnknownSync(Messages.ResponseCreateIdentity)(await res.json());
- Identity.storeAccountId(storage, accountId);
- Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
- Identity.storeKeys(storage, accountId, keys);
- }
-
- async function login(signer: Identity.Signer) {
- if (!signer) {
- return;
- }
- const address = await signer.getAddress();
- if (!address) {
- return;
- }
- const accountId = getAddress(address);
- const keys = Identity.loadKeys(storage, accountId);
- if (!keys && !(await identityExists(accountId))) {
- await signup(signer, accountId);
- } else if (keys) {
- await loginWithKeys(keys, accountId);
- } else {
- await loginWithWallet(signer, accountId).then(() => restoreKeys(signer, accountId));
- }
- console.log('Identity initialized');
- setAuthState({
- authenticated: true,
- accountId,
- sessionToken: Identity.loadSyncServerSessionToken(storage, accountId),
- keys: Identity.loadKeys(storage, accountId),
- });
- store.send({ type: 'reset' });
- }
-
- function logout() {
- const accountId = Identity.loadAccountId(storage) ?? authState.accountId;
- Identity.wipeAccountId(storage);
- if (!accountId) {
- return;
- }
- Identity.wipeKeys(storage, accountId);
- Identity.wipeSyncServerSessionToken(storage, accountId);
- setAuthState({ authenticated: false, accountId: null, sessionToken: null, keys: null });
- }
-
- const setIdentityAndSessionToken = useCallback(
- (account: Identity.Identity & { sessionToken: string }) => {
- Identity.storeAccountId(storage, account.accountId);
- Identity.storeSyncServerSessionToken(storage, account.accountId, account.sessionToken);
- Identity.storeKeys(storage, account.accountId, {
- encryptionPublicKey: account.encryptionPublicKey,
- encryptionPrivateKey: account.encryptionPrivateKey,
- signaturePublicKey: account.signaturePublicKey,
- signaturePrivateKey: account.signaturePrivateKey,
- });
- store.send({ type: 'reset' });
- setAuthState({
- authenticated: true,
- accountId: getAddress(account.accountId),
- sessionToken: account.sessionToken,
- keys: {
- encryptionPublicKey: account.encryptionPublicKey,
- encryptionPrivateKey: account.encryptionPrivateKey,
- signaturePublicKey: account.signaturePublicKey,
- signaturePrivateKey: account.signaturePrivateKey,
- },
- });
- console.log('Identity set');
- },
- [storage],
- );
-
- // check if the user is already authenticated on initial render
- const initialRenderAuthCheckRef = useRef(false);
- useEffect(() => {
- if (!initialRenderAuthCheckRef.current) {
- const accountId = Identity.loadAccountId(storage);
- if (accountId) {
- const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
- if (sessionToken) {
- const keys = Identity.loadKeys(storage, accountId);
- if (keys) {
- // user is already authenticated, set state
- setAuthState({ authenticated: true, accountId: getAddress(accountId), sessionToken, keys });
- }
- }
- }
- // set render auth check to true so next potential rerender doesn't proc this
- initialRenderAuthCheckRef.current = true;
- }
- }, [storage]);
-
- return (
-
- {children}
-
- );
-}
-
-type HypergraphAuthState = {
- authenticated: boolean;
- accountId: Address | null;
- sessionToken: string | null;
- keys: Identity.IdentityKeys | null;
-};
diff --git a/packages/hypergraph-react/src/index.ts b/packages/hypergraph-react/src/index.ts
index c261faaf..b8206a03 100644
--- a/packages/hypergraph-react/src/index.ts
+++ b/packages/hypergraph-react/src/index.ts
@@ -1,3 +1,2 @@
export * as Hypergraph from './HypergraphAppContext.js';
-export * as Auth from './HypergraphAuthContext.js';
export * as Space from './HypergraphSpaceContext.js';
diff --git a/packages/hypergraph-react/test/HypergraphAuthContext.test.tsx b/packages/hypergraph-react/test/HypergraphAppContext.test.tsx
similarity index 74%
rename from packages/hypergraph-react/test/HypergraphAuthContext.test.tsx
rename to packages/hypergraph-react/test/HypergraphAppContext.test.tsx
index a350a033..d94c0597 100644
--- a/packages/hypergraph-react/test/HypergraphAuthContext.test.tsx
+++ b/packages/hypergraph-react/test/HypergraphAppContext.test.tsx
@@ -4,7 +4,7 @@ import { cleanup, renderHook } from '@testing-library/react';
import React from 'react';
import { afterEach, describe, expect, it } from 'vitest';
-import { HypergraphAuthProvider, useAuthenticated, useHypergraphAccountId } from '../src/HypergraphAuthContext.js';
+import { HypergraphAppProvider, useAuthenticated, useHypergraphAccountId } from '../src/HypergraphAppContext.js';
afterEach(() => {
cleanup();
@@ -23,10 +23,10 @@ const storageMock = {
},
};
-describe('HypergraphAuthContext', () => {
- it('should render the HypergraphAuthProvider and be initially unauthenticetd', async () => {
+describe('HypergraphAppContext', () => {
+ it('should render the HypergraphAppProvider and be initially unauthenticated', async () => {
const wrapper = ({ children }: Readonly<{ children: React.ReactNode }>) => (
- {children}
+ {children}
);
const { result: authenticatedResult } = renderHook(() => useAuthenticated(), { wrapper });