+2
-1
deno.json
+2
-1
deno.json
+10
deno.lock
+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
+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
+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
+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
+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
+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
+
);