zustand and local identity

+2 -1
deno.json
··· 39 39 "react-router-dom": "npm:react-router-dom@^7.5.1", 40 40 "vite": "npm:vite@^6.3.2", 41 41 "zod": "npm:zod@3", 42 - "zustand": "npm:zustand@^5.0.5" 42 + "zustand": "npm:zustand@^5.0.5", 43 + "zustand-slices": "npm:zustand-slices@^0.4.0" 43 44 }, 44 45 "nodeModulesDir": "auto", 45 46
+10
deno.lock
··· 29 29 "npm:react-dom@^19.1.0": "19.1.0_react@19.1.0", 30 30 "npm:react-router-dom@^7.5.1": "7.6.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", 31 31 "npm:react@^19.1.0": "19.1.0", 32 + "npm:vite@*": "6.3.5_picomatch@4.0.2_@types+node@22.15.15", 32 33 "npm:vite@^6.3.2": "6.3.5_picomatch@4.0.2_@types+node@22.15.15", 33 34 "npm:zod@3": "3.25.53", 35 + "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", 34 36 "npm:zustand@^5.0.5": "5.0.5_@types+react@19.1.6_react@19.1.0" 35 37 }, 36 38 "jsr": { ··· 1642 1644 "zod@3.25.53": { 1643 1645 "integrity": "sha512-BKOKoY3XcGUVkqaalCtFK15LhwR0G0i65AClFpWSXLN2gJNBGlTktukHgwexCTa/dAacPPp9ReryXPWyeZF4LQ==" 1644 1646 }, 1647 + "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": { 1648 + "integrity": "sha512-Hs+JJc6wIFWwzefe/RLeGuWQgE3ZHQubLRaWyeJ3fFIKrM4gl2VeRZEunbIA33R5ashZyRbZ36ZrPybEUL+yJA==", 1649 + "dependencies": [ 1650 + "react", 1651 + "zustand" 1652 + ] 1653 + }, 1645 1654 "zustand@5.0.5_@types+react@19.1.6_react@19.1.0": { 1646 1655 "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", 1647 1656 "dependencies": [ ··· 1670 1679 "npm:react@^19.1.0", 1671 1680 "npm:vite@^6.3.2", 1672 1681 "npm:zod@3", 1682 + "npm:zustand-slices@0.4", 1673 1683 "npm:zustand@^5.0.5" 1674 1684 ] 1675 1685 }
+33 -17
src/client/src/App.tsx
··· 1 1 import "./App.css"; 2 2 3 - import { useState } from "react"; 3 + import { useSkypodStore } from "@repo/client/store.ts"; 4 + import { startTransition, useCallback } from "react"; 5 + 6 + import { generateIdentity } from "../store-ident.ts"; 4 7 5 8 function App() { 6 - const [count, setCount] = useState(0); 9 + const ident = useSkypodStore((s) => s.identity); 10 + const setIdentity = useSkypodStore((s) => s.setIdentity); 7 11 8 - return ( 9 - <> 10 - <div className="card"> 11 - <button type="button" onClick={() => setCount((count: number) => count + 1)}> 12 - count is {count} 13 - </button> 14 - <p> 15 - Edit <code>src/App.tsx</code> and save to test HMR 16 - </p> 17 - </div> 18 - <p className="read-the-docs"> 19 - Click on the Vite and React logos to learn more 20 - </p> 21 - </> 22 - ); 12 + const signin = useCallback(() => { 13 + startTransition(async () => { 14 + setIdentity(await generateIdentity()); 15 + }); 16 + }, [setIdentity]); 17 + 18 + return ident.initialized 19 + ? ident.anonymous 20 + ? ( 21 + <> 22 + <h1>anonymous!</h1> 23 + <button type="button" onClick={signin}>Sign In</button> 24 + </> 25 + ) 26 + : ( 27 + <> 28 + <h1> 29 + <code>{ident.authenticated.identid}</code> 30 + </h1> 31 + <button type="button" onClick={signin}>Regenerate</button> 32 + </> 33 + ) 34 + : ( 35 + <> 36 + <h1>Initializing...</h1> 37 + </> 38 + ); 23 39 } 24 40 25 41 export default App;
+60
src/client/storage-install-bound.ts
··· 1 + import { Cipher, deriveCipher } from "@repo/common/crypto-enc.ts"; 2 + import { nanoid } from "@sitnik/nanoid"; 3 + import type { StateStorage } from "zustand/middleware"; 4 + 5 + function isEncryptionEnabled(enabled?: boolean) { 6 + return enabled ?? !import.meta.env.DEV; 7 + } 8 + 9 + function isEncryptedValue(value?: unknown): value is string { 10 + return value != null && typeof value === "string" && value.startsWith("enc:"); 11 + } 12 + 13 + export function makeInstallBoundStorage( 14 + name: string, 15 + encrypt: boolean | undefined, 16 + baseStorage: StateStorage, 17 + ): StateStorage { 18 + if (!isEncryptionEnabled(encrypt)) return baseStorage; 19 + 20 + async function ensureNonce() { 21 + const key = `nonce:${name}`; 22 + let nonce = await baseStorage.getItem(key); 23 + if (!nonce) { 24 + nonce = nanoid(32); 25 + await baseStorage.setItem(key, nonce); 26 + } 27 + 28 + return nonce; 29 + } 30 + 31 + let crypto: Cipher | undefined; 32 + async function ensureCrypto() { 33 + return (crypto ??= deriveCipher( 34 + name, 35 + location.origin, 36 + await ensureNonce(), 37 + )); 38 + } 39 + 40 + return { 41 + async getItem(name: string) { 42 + const value = await Promise.resolve(baseStorage.getItem(name)); 43 + if (!isEncryptedValue(value)) { 44 + return null; 45 + } 46 + 47 + const crypto = await ensureCrypto(); 48 + return await crypto.decrypt(value.slice(4)); 49 + }, 50 + 51 + async setItem(name: string, value: string) { 52 + const crypto = await ensureCrypto(); 53 + baseStorage.setItem(name, `enc:${await crypto.encrypt(value)}`); 54 + }, 55 + 56 + removeItem(name: string) { 57 + baseStorage.removeItem(name); 58 + }, 59 + }; 60 + }
+85
src/client/storage-serializers.ts
··· 1 + // deno-lint-ignore-file no-explicit-any 2 + 3 + import type { PersistStorage, StateStorage } from "zustand/middleware"; 4 + 5 + type MaybeAsync<U> = U | Promise<U>; 6 + 7 + export type SerializerConfig<TValue> = { 8 + serializer: (value: TValue) => any | Promise<any>; 9 + deserializer: (value: unknown) => TValue | Promise<TValue>; 10 + initializer?: () => MaybeAsync<TValue>; 11 + }; 12 + 13 + export type SerializerMap<T> = { 14 + [K in keyof T]?: SerializerConfig<T[K]>; 15 + }; 16 + 17 + export function makeSlicedSerializerStorage<T extends object>( 18 + storage: StateStorage, 19 + serializers: SerializerMap<T>, 20 + ): PersistStorage<T> { 21 + // serialize by looping through the state and using registered serializers per key 22 + async function serializeState(state: T) { 23 + const output = {} as Record<string, unknown>; 24 + for (const key in state) { 25 + let value = await state[key]; 26 + if (serializers[key]) { 27 + value = await serializers[key].serializer(value); 28 + } 29 + 30 + output[key] = value; 31 + } 32 + 33 + return output; 34 + } 35 + 36 + // deserialize by looping through the state and using registered deserializers per key 37 + async function deserializeState(state: any) { 38 + const output = {} as T; 39 + for (const key_ in state) { 40 + const key = key_ as keyof T; 41 + output[key] = state[key]; 42 + if (serializers[key]) { 43 + output[key] = await serializers[key].deserializer(output[key]); 44 + } 45 + } 46 + 47 + return output; 48 + } 49 + 50 + // initialize also run here, so we can provide default initial content as if it's coming from the store 51 + async function initializeState() { 52 + const state = {} as T; 53 + for (const key in serializers) { 54 + const initializer = serializers[key]?.initializer; 55 + if (initializer) { 56 + state[key] = await initializer(); 57 + } 58 + } 59 + 60 + return state as T; 61 + } 62 + 63 + return { 64 + async getItem(name) { 65 + const json = await storage.getItem(name); 66 + if (json == null) { 67 + return { version: 0, state: await initializeState() }; 68 + } 69 + 70 + const { version, state } = JSON.parse(json); 71 + return { version, state: await deserializeState(state) }; 72 + }, 73 + 74 + async setItem(name, input) { 75 + const state = await serializeState(input.state); 76 + const json = JSON.stringify({ version: input.version, state }); 77 + 78 + storage.setItem(name, json); 79 + }, 80 + 81 + removeItem(name) { 82 + storage.removeItem(name); 83 + }, 84 + }; 85 + }
+119
src/client/store-ident.ts
··· 1 + import * as jose from "@panva/jose"; 2 + import { signAlgo } from "@repo/common/crypto-algos.ts"; 3 + import { generateJwkPair, jwkPairSchema, serializeJwkPair } from "@repo/common/crypto-sig.ts"; 4 + import { generateIdentId, identIdSchema } from "@repo/schema/state.ts"; 5 + import { z } from "zod/v4"; 6 + import { createSlice } from "zustand-slices"; 7 + 8 + import { sleep } from "../common/sleep.ts"; 9 + import { SerializerConfig } from "./storage-serializers.ts"; 10 + 11 + // the state object 12 + 13 + export type IdentityData = z.infer<typeof identityDataSchema>; 14 + 15 + type InitializedData = IdentityData & { initialized: true }; 16 + type AuthenticatedData = InitializedData & { anonymous: false }; 17 + type AnonymousData = InitializedData & { anonymous: true }; 18 + 19 + export const identityDataSchema = z.union([ 20 + z.object({ 21 + initialized: z.literal(false), 22 + }), 23 + z.union([ 24 + z.object({ 25 + initialized: z.literal(true), 26 + anonymous: z.literal(true), 27 + }), 28 + z.object({ 29 + initialized: z.literal(true), 30 + anonymous: z.literal(false), 31 + version: z.number(), 32 + authenticated: z.object({ 33 + identid: identIdSchema, 34 + keypair: jwkPairSchema, 35 + }), 36 + }), 37 + ]), 38 + ]); 39 + 40 + export const identitySlice = createSlice({ 41 + name: "identity", 42 + value: { initialized: false } as IdentityData, 43 + actions: { 44 + setIdentity: (identity: IdentityData) => () => identity, 45 + }, 46 + }); 47 + 48 + export const identitySliceSerializer: SerializerConfig<IdentityData> = { 49 + initializer: async () => { 50 + await sleep(1500); 51 + 52 + return { 53 + initialized: true, 54 + anonymous: true, 55 + }; 56 + }, 57 + 58 + serializer: async (state) => { 59 + if (!state.initialized) return state; 60 + if (state.anonymous) return state; 61 + 62 + const { 63 + authenticated: { 64 + keypair, 65 + ...authrest 66 + }, 67 + ...staterest 68 + } = state; 69 + 70 + return { 71 + ...staterest, 72 + authenticated: { 73 + ...authrest, 74 + keypair: await serializeJwkPair(keypair), 75 + }, 76 + }; 77 + }, 78 + 79 + deserializer: (state) => identityDataSchema.parseAsync(state), 80 + }; 81 + 82 + // helpers 83 + 84 + export function isInitialized(ident?: IdentityData): ident is InitializedData { 85 + return ident?.initialized ?? false; 86 + } 87 + 88 + export function isAuthenticated(ident?: IdentityData): ident is AuthenticatedData { 89 + return (ident?.initialized && !ident?.anonymous) === true; 90 + } 91 + 92 + export async function generateIdentity() { 93 + return { 94 + version: 0, 95 + initialized: true, 96 + anonymous: false, 97 + authenticated: { 98 + identid: generateIdentId(), 99 + keypair: await generateJwkPair(), 100 + }, 101 + } satisfies IdentityData; 102 + } 103 + 104 + export function signJwt(ident: IdentityData, payload: jose.JWTPayload) { 105 + if (!isAuthenticated(ident)) { 106 + throw new TypeError("cannot sign JWTs with an anonymous identity!"); 107 + } 108 + 109 + const jwt = new jose.SignJWT(payload) 110 + .setProtectedHeader({ alg: signAlgo.name }) 111 + .setIssuer(ident.authenticated.identid) 112 + .setIssuedAt(); 113 + 114 + if (!payload.exp) { 115 + jwt.setExpirationTime("1m"); 116 + } 117 + 118 + return jwt.sign(ident.authenticated.keypair.privateKey); 119 + }
+29
src/client/store.ts
··· 1 + import { create } from "zustand"; 2 + import { devtools, persist } from "zustand/middleware"; 3 + import { withSlices } from "zustand-slices"; 4 + 5 + import { makeSlicedSerializerStorage } from "./storage-serializers.ts"; 6 + import { identitySlice, identitySliceSerializer } from "./store-ident.ts"; 7 + 8 + type SkypodState = ReturnType<typeof skypodStore>; 9 + 10 + const skypodStore = withSlices(identitySlice); 11 + const skypodStorage = makeSlicedSerializerStorage<SkypodState>(localStorage, { 12 + identity: identitySliceSerializer, 13 + }); 14 + 15 + export const useSkypodStore = create( 16 + devtools( 17 + persist( 18 + skypodStore, 19 + { 20 + name: "skypod", 21 + storage: skypodStorage, 22 + }, 23 + ), 24 + { 25 + anonymousActionType: "@zustand/action", 26 + name: "skypod", 27 + }, 28 + ), 29 + );