diff --git a/deno.json b/deno.json
index b434e4d..bd199f0 100644
--- a/deno.json
+++ b/deno.json
@@ -39,7 +39,8 @@
"react-router-dom": "npm:react-router-dom@^7.5.1",
"vite": "npm:vite@^6.3.2",
"zod": "npm:zod@3",
- "zustand": "npm:zustand@^5.0.5"
+ "zustand": "npm:zustand@^5.0.5",
+ "zustand-slices": "npm:zustand-slices@^0.4.0"
},
"nodeModulesDir": "auto",
diff --git a/deno.lock b/deno.lock
index 27f68ff..057d788 100644
--- a/deno.lock
+++ b/deno.lock
@@ -29,8 +29,10 @@
"npm:react-dom@^19.1.0": "19.1.0_react@19.1.0",
"npm:react-router-dom@^7.5.1": "7.6.2_react@19.1.0_react-dom@19.1.0__react@19.1.0",
"npm:react@^19.1.0": "19.1.0",
+ "npm:vite@*": "6.3.5_picomatch@4.0.2_@types+node@22.15.15",
"npm:vite@^6.3.2": "6.3.5_picomatch@4.0.2_@types+node@22.15.15",
"npm:zod@3": "3.25.53",
+ "npm:zustand-slices@0.4": "0.4.0_react@19.1.0_zustand@5.0.5__@types+react@19.1.6__react@19.1.0_@types+react@19.1.6",
"npm:zustand@^5.0.5": "5.0.5_@types+react@19.1.6_react@19.1.0"
},
"jsr": {
@@ -1642,6 +1644,13 @@
"zod@3.25.53": {
"integrity": "sha512-BKOKoY3XcGUVkqaalCtFK15LhwR0G0i65AClFpWSXLN2gJNBGlTktukHgwexCTa/dAacPPp9ReryXPWyeZF4LQ=="
},
+ "zustand-slices@0.4.0_react@19.1.0_zustand@5.0.5__@types+react@19.1.6__react@19.1.0_@types+react@19.1.6": {
+ "integrity": "sha512-Hs+JJc6wIFWwzefe/RLeGuWQgE3ZHQubLRaWyeJ3fFIKrM4gl2VeRZEunbIA33R5ashZyRbZ36ZrPybEUL+yJA==",
+ "dependencies": [
+ "react",
+ "zustand"
+ ]
+ },
"zustand@5.0.5_@types+react@19.1.6_react@19.1.0": {
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
"dependencies": [
@@ -1670,6 +1679,7 @@
"npm:react@^19.1.0",
"npm:vite@^6.3.2",
"npm:zod@3",
+ "npm:zustand-slices@0.4",
"npm:zustand@^5.0.5"
]
}
diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx
index 7d23113..7d212da 100644
--- a/src/client/src/App.tsx
+++ b/src/client/src/App.tsx
@@ -1,25 +1,41 @@
import "./App.css";
-import { useState } from "react";
+import { useSkypodStore } from "@repo/client/store.ts";
+import { startTransition, useCallback } from "react";
+
+import { generateIdentity } from "../store-ident.ts";
function App() {
- const [count, setCount] = useState(0);
+ const ident = useSkypodStore((s) => s.identity);
+ const setIdentity = useSkypodStore((s) => s.setIdentity);
+
+ const signin = useCallback(() => {
+ startTransition(async () => {
+ setIdentity(await generateIdentity());
+ });
+ }, [setIdentity]);
- return (
- <>
-
-
-
- Edit src/App.tsx and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
- >
- );
+ return ident.initialized
+ ? ident.anonymous
+ ? (
+ <>
+ anonymous!
+
+ >
+ )
+ : (
+ <>
+
+ {ident.authenticated.identid}
+
+
+ >
+ )
+ : (
+ <>
+ Initializing...
+ >
+ );
}
export default App;
diff --git a/src/client/storage-install-bound.ts b/src/client/storage-install-bound.ts
new file mode 100644
index 0000000..cb63afd
--- /dev/null
+++ b/src/client/storage-install-bound.ts
@@ -0,0 +1,60 @@
+import { Cipher, deriveCipher } from "@repo/common/crypto-enc.ts";
+import { nanoid } from "@sitnik/nanoid";
+import type { StateStorage } from "zustand/middleware";
+
+function isEncryptionEnabled(enabled?: boolean) {
+ return enabled ?? !import.meta.env.DEV;
+}
+
+function isEncryptedValue(value?: unknown): value is string {
+ return value != null && typeof value === "string" && value.startsWith("enc:");
+}
+
+export function makeInstallBoundStorage(
+ name: string,
+ encrypt: boolean | undefined,
+ baseStorage: StateStorage,
+): StateStorage {
+ if (!isEncryptionEnabled(encrypt)) return baseStorage;
+
+ async function ensureNonce() {
+ const key = `nonce:${name}`;
+ let nonce = await baseStorage.getItem(key);
+ if (!nonce) {
+ nonce = nanoid(32);
+ await baseStorage.setItem(key, nonce);
+ }
+
+ return nonce;
+ }
+
+ let crypto: Cipher | undefined;
+ async function ensureCrypto() {
+ return (crypto ??= deriveCipher(
+ name,
+ location.origin,
+ await ensureNonce(),
+ ));
+ }
+
+ return {
+ async getItem(name: string) {
+ const value = await Promise.resolve(baseStorage.getItem(name));
+ if (!isEncryptedValue(value)) {
+ return null;
+ }
+
+ const crypto = await ensureCrypto();
+ return await crypto.decrypt(value.slice(4));
+ },
+
+ async setItem(name: string, value: string) {
+ const crypto = await ensureCrypto();
+ baseStorage.setItem(name, `enc:${await crypto.encrypt(value)}`);
+ },
+
+ removeItem(name: string) {
+ baseStorage.removeItem(name);
+ },
+ };
+}
diff --git a/src/client/storage-serializers.ts b/src/client/storage-serializers.ts
new file mode 100644
index 0000000..20e0afd
--- /dev/null
+++ b/src/client/storage-serializers.ts
@@ -0,0 +1,85 @@
+// deno-lint-ignore-file no-explicit-any
+
+import type { PersistStorage, StateStorage } from "zustand/middleware";
+
+type MaybeAsync = U | Promise;
+
+export type SerializerConfig = {
+ serializer: (value: TValue) => any | Promise;
+ deserializer: (value: unknown) => TValue | Promise;
+ initializer?: () => MaybeAsync;
+};
+
+export type SerializerMap = {
+ [K in keyof T]?: SerializerConfig;
+};
+
+export function makeSlicedSerializerStorage(
+ storage: StateStorage,
+ serializers: SerializerMap,
+): PersistStorage {
+ // serialize by looping through the state and using registered serializers per key
+ async function serializeState(state: T) {
+ const output = {} as Record;
+ for (const key in state) {
+ let value = await state[key];
+ if (serializers[key]) {
+ value = await serializers[key].serializer(value);
+ }
+
+ output[key] = value;
+ }
+
+ return output;
+ }
+
+ // deserialize by looping through the state and using registered deserializers per key
+ async function deserializeState(state: any) {
+ const output = {} as T;
+ for (const key_ in state) {
+ const key = key_ as keyof T;
+ output[key] = state[key];
+ if (serializers[key]) {
+ output[key] = await serializers[key].deserializer(output[key]);
+ }
+ }
+
+ return output;
+ }
+
+ // initialize also run here, so we can provide default initial content as if it's coming from the store
+ async function initializeState() {
+ const state = {} as T;
+ for (const key in serializers) {
+ const initializer = serializers[key]?.initializer;
+ if (initializer) {
+ state[key] = await initializer();
+ }
+ }
+
+ return state as T;
+ }
+
+ return {
+ async getItem(name) {
+ const json = await storage.getItem(name);
+ if (json == null) {
+ return { version: 0, state: await initializeState() };
+ }
+
+ const { version, state } = JSON.parse(json);
+ return { version, state: await deserializeState(state) };
+ },
+
+ async setItem(name, input) {
+ const state = await serializeState(input.state);
+ const json = JSON.stringify({ version: input.version, state });
+
+ storage.setItem(name, json);
+ },
+
+ removeItem(name) {
+ storage.removeItem(name);
+ },
+ };
+}
diff --git a/src/client/store-ident.ts b/src/client/store-ident.ts
new file mode 100644
index 0000000..bdf5ac7
--- /dev/null
+++ b/src/client/store-ident.ts
@@ -0,0 +1,119 @@
+import * as jose from "@panva/jose";
+import { signAlgo } from "@repo/common/crypto-algos.ts";
+import { generateJwkPair, jwkPairSchema, serializeJwkPair } from "@repo/common/crypto-sig.ts";
+import { generateIdentId, identIdSchema } from "@repo/schema/state.ts";
+import { z } from "zod/v4";
+import { createSlice } from "zustand-slices";
+
+import { sleep } from "../common/sleep.ts";
+import { SerializerConfig } from "./storage-serializers.ts";
+
+// the state object
+
+export type IdentityData = z.infer;
+
+type InitializedData = IdentityData & { initialized: true };
+type AuthenticatedData = InitializedData & { anonymous: false };
+type AnonymousData = InitializedData & { anonymous: true };
+
+export const identityDataSchema = z.union([
+ z.object({
+ initialized: z.literal(false),
+ }),
+ z.union([
+ z.object({
+ initialized: z.literal(true),
+ anonymous: z.literal(true),
+ }),
+ z.object({
+ initialized: z.literal(true),
+ anonymous: z.literal(false),
+ version: z.number(),
+ authenticated: z.object({
+ identid: identIdSchema,
+ keypair: jwkPairSchema,
+ }),
+ }),
+ ]),
+]);
+
+export const identitySlice = createSlice({
+ name: "identity",
+ value: { initialized: false } as IdentityData,
+ actions: {
+ setIdentity: (identity: IdentityData) => () => identity,
+ },
+});
+
+export const identitySliceSerializer: SerializerConfig = {
+ initializer: async () => {
+ await sleep(1500);
+
+ return {
+ initialized: true,
+ anonymous: true,
+ };
+ },
+
+ serializer: async (state) => {
+ if (!state.initialized) return state;
+ if (state.anonymous) return state;
+
+ const {
+ authenticated: {
+ keypair,
+ ...authrest
+ },
+ ...staterest
+ } = state;
+
+ return {
+ ...staterest,
+ authenticated: {
+ ...authrest,
+ keypair: await serializeJwkPair(keypair),
+ },
+ };
+ },
+
+ deserializer: (state) => identityDataSchema.parseAsync(state),
+};
+
+// helpers
+
+export function isInitialized(ident?: IdentityData): ident is InitializedData {
+ return ident?.initialized ?? false;
+}
+
+export function isAuthenticated(ident?: IdentityData): ident is AuthenticatedData {
+ return (ident?.initialized && !ident?.anonymous) === true;
+}
+
+export async function generateIdentity() {
+ return {
+ version: 0,
+ initialized: true,
+ anonymous: false,
+ authenticated: {
+ identid: generateIdentId(),
+ keypair: await generateJwkPair(),
+ },
+ } satisfies IdentityData;
+}
+
+export function signJwt(ident: IdentityData, payload: jose.JWTPayload) {
+ if (!isAuthenticated(ident)) {
+ throw new TypeError("cannot sign JWTs with an anonymous identity!");
+ }
+
+ const jwt = new jose.SignJWT(payload)
+ .setProtectedHeader({ alg: signAlgo.name })
+ .setIssuer(ident.authenticated.identid)
+ .setIssuedAt();
+
+ if (!payload.exp) {
+ jwt.setExpirationTime("1m");
+ }
+
+ return jwt.sign(ident.authenticated.keypair.privateKey);
+}
diff --git a/src/client/store.ts b/src/client/store.ts
new file mode 100644
index 0000000..781a695
--- /dev/null
+++ b/src/client/store.ts
@@ -0,0 +1,29 @@
+import { create } from "zustand";
+import { devtools, persist } from "zustand/middleware";
+import { withSlices } from "zustand-slices";
+
+import { makeSlicedSerializerStorage } from "./storage-serializers.ts";
+import { identitySlice, identitySliceSerializer } from "./store-ident.ts";
+
+type SkypodState = ReturnType;
+
+const skypodStore = withSlices(identitySlice);
+const skypodStorage = makeSlicedSerializerStorage(localStorage, {
+ identity: identitySliceSerializer,
+});
+
+export const useSkypodStore = create(
+ devtools(
+ persist(
+ skypodStore,
+ {
+ name: "skypod",
+ storage: skypodStorage,
+ },
+ ),
+ {
+ anonymousActionType: "@zustand/action",
+ name: "skypod",
+ },
+ ),
+);