Skip to content

Commit ce8f158

Browse files
wmxscottclaude
andauthored
Implement room creation flow with JWT-based session setup (#10)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent acb3b05 commit ce8f158

3 files changed

Lines changed: 255 additions & 2 deletions

File tree

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,38 @@
1+
import { useState } from 'react'
2+
import { createRoom } from '../../lib/room/createRoom'
3+
14
export default function LandingPage() {
5+
const [loading, setLoading] = useState(false)
6+
const [error, setError] = useState<string | null>(null)
7+
8+
const handleStart = async () => {
9+
setLoading(true)
10+
setError(null)
11+
try {
12+
await createRoom()
13+
} catch (err) {
14+
console.error(err)
15+
setError('Failed to create session. Please try again.')
16+
setLoading(false)
17+
}
18+
}
19+
220
return (
3-
<div>
21+
<div
22+
style={{
23+
display: 'flex',
24+
flexDirection: 'column',
25+
alignItems: 'center',
26+
justifyContent: 'center',
27+
height: '100vh',
28+
}}
29+
>
430
<h1>Ratifyd</h1>
5-
<p>Landing</p>
31+
<p>Ephemeral technical interviews. No account required.</p>
32+
<button onClick={handleStart} disabled={loading}>
33+
{loading ? 'Creating session...' : 'Start Session'}
34+
</button>
35+
{error && <p style={{ color: 'red' }}>{error}</p>}
636
</div>
737
)
838
}

src/lib/room/createRoom.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { createRoom } from './createRoom'
3+
import {
4+
loadPeerId,
5+
loadSigningPrivateKey,
6+
loadSigningPublicKey,
7+
loadOaepPrivateKey,
8+
loadOaepPublicKeyB64,
9+
loadRoomKey,
10+
} from '../crypto/storage'
11+
import { ROLES, JWT_EXPIRY_SECONDS, STORAGE_KEYS } from '../../constants'
12+
13+
function parseFragment(): { token: string | null } {
14+
const hash = window.location.hash.slice(1)
15+
if (!hash) return { token: null }
16+
const params = new URLSearchParams(hash)
17+
return { token: params.get('token') }
18+
}
19+
20+
function decodeJWTPayload(raw: string): Record<string, unknown> {
21+
const parts = raw.split('.')
22+
if (parts.length !== 3) throw new Error('Invalid JWT')
23+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
24+
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
25+
return JSON.parse(atob(padded)) as Record<string, unknown>
26+
}
27+
28+
beforeEach(() => {
29+
localStorage.clear()
30+
window.location.hash = ''
31+
})
32+
33+
describe('createRoom', () => {
34+
it('sets the URL fragment to #token=<jwt> with no other params', async () => {
35+
await createRoom()
36+
const hash = window.location.hash.slice(1)
37+
const params = new URLSearchParams(hash)
38+
expect(params.get('token')).not.toBeNull()
39+
// Ensure the only key in the fragment is "token"
40+
const keys = Array.from(params.keys())
41+
expect(keys).toEqual(['token'])
42+
})
43+
44+
it('JWT payload has role=owner', async () => {
45+
await createRoom()
46+
const { token } = parseFragment()
47+
expect(token).not.toBeNull()
48+
const payload = decodeJWTPayload(token!)
49+
expect(payload.role).toBe(ROLES.OWNER)
50+
})
51+
52+
it('JWT payload has a UUID room field', async () => {
53+
await createRoom()
54+
const { token } = parseFragment()
55+
const payload = decodeJWTPayload(token!)
56+
expect(typeof payload.room).toBe('string')
57+
expect((payload.room as string).length).toBeGreaterThan(0)
58+
// UUID format
59+
expect(payload.room).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
60+
})
61+
62+
it('JWT payload iss matches the saved peerId', async () => {
63+
await createRoom()
64+
const peerId = loadPeerId()
65+
expect(peerId).not.toBeNull()
66+
const { token } = parseFragment()
67+
const payload = decodeJWTPayload(token!)
68+
expect(payload.iss).toBe(peerId)
69+
})
70+
71+
it('JWT payload has a jti field', async () => {
72+
await createRoom()
73+
const { token } = parseFragment()
74+
const payload = decodeJWTPayload(token!)
75+
expect(typeof payload.jti).toBe('string')
76+
expect((payload.jti as string).length).toBeGreaterThan(0)
77+
})
78+
79+
it('JWT payload exp - iat equals JWT_EXPIRY_SECONDS', async () => {
80+
await createRoom()
81+
const { token } = parseFragment()
82+
const payload = decodeJWTPayload(token!)
83+
expect((payload.exp as number) - (payload.iat as number)).toBe(JWT_EXPIRY_SECONDS)
84+
})
85+
86+
it('saves signing keypair to localStorage under peerId', async () => {
87+
await createRoom()
88+
const peerId = loadPeerId()!
89+
const privKey = await loadSigningPrivateKey(peerId)
90+
const pubKey = await loadSigningPublicKey(peerId)
91+
expect(privKey).not.toBeNull()
92+
expect(pubKey).not.toBeNull()
93+
})
94+
95+
it('saves OAEP keypair to localStorage under peerId', async () => {
96+
await createRoom()
97+
const peerId = loadPeerId()!
98+
const privKey = await loadOaepPrivateKey(peerId)
99+
const pubKeyB64 = loadOaepPublicKeyB64(peerId)
100+
expect(privKey).not.toBeNull()
101+
expect(pubKeyB64).not.toBeNull()
102+
})
103+
104+
it('saves room key to localStorage under roomId from JWT', async () => {
105+
await createRoom()
106+
const { token } = parseFragment()
107+
const payload = decodeJWTPayload(token!)
108+
const roomId = payload.room as string
109+
const roomKey = await loadRoomKey(roomId)
110+
expect(roomKey).not.toBeNull()
111+
})
112+
113+
it('does not put any public key in the URL', async () => {
114+
await createRoom()
115+
const hash = window.location.hash
116+
// Public keys are base64url-encoded SPKI blobs — they are long (>200 chars)
117+
// Verify no param value is a long base64url string that could be a key
118+
const params = new URLSearchParams(hash.slice(1))
119+
for (const [key, value] of params.entries()) {
120+
if (key !== 'token') {
121+
// Any non-token param with a long value is suspicious
122+
expect(value.length).toBeLessThan(200)
123+
}
124+
}
125+
// The token itself is the only thing in the URL — no separate pubkey param
126+
expect(params.has('signingPub')).toBe(false)
127+
expect(params.has('oaepPub')).toBe(false)
128+
expect(params.has('pubkey')).toBe(false)
129+
})
130+
131+
it('generates distinct roomId and ownerId on each call', async () => {
132+
await createRoom()
133+
const peerId1 = loadPeerId()!
134+
const hash1 = window.location.hash
135+
136+
localStorage.clear()
137+
window.location.hash = ''
138+
139+
await createRoom()
140+
const peerId2 = loadPeerId()!
141+
const hash2 = window.location.hash
142+
143+
expect(peerId1).not.toBe(peerId2)
144+
expect(hash1).not.toBe(hash2)
145+
})
146+
147+
it('JWT is signed with the stored signing private key', async () => {
148+
await createRoom()
149+
const peerId = loadPeerId()!
150+
const pubKey = await loadSigningPublicKey(peerId)
151+
expect(pubKey).not.toBeNull()
152+
153+
const { token } = parseFragment()
154+
const parts = token!.split('.')
155+
const sigB64 = parts[2].replace(/-/g, '+').replace(/_/g, '/')
156+
const padded = sigB64.padEnd(sigB64.length + ((4 - (sigB64.length % 4)) % 4), '=')
157+
const sigBuf = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))
158+
159+
const verified = await crypto.subtle.verify(
160+
{ name: 'RSASSA-PKCS1-v1_5' },
161+
pubKey!,
162+
sigBuf,
163+
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
164+
)
165+
expect(verified).toBe(true)
166+
})
167+
168+
it('saves peerId to localStorage under the PEER_ID key', async () => {
169+
await createRoom()
170+
const peerId = localStorage.getItem(STORAGE_KEYS.PEER_ID)
171+
expect(peerId).not.toBeNull()
172+
expect(typeof peerId).toBe('string')
173+
expect(peerId!.length).toBeGreaterThan(0)
174+
})
175+
})

src/lib/room/createRoom.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { generateSigningKeyPair } from '../crypto/signing'
2+
import { generateOaepKeyPair } from '../crypto/oaep'
3+
import { generateRoomKey } from '../crypto/roomKey'
4+
import { saveSigningKeyPair, saveOaepKeyPair, saveRoomKey, savePeerId } from '../crypto/storage'
5+
import { mintJWT } from '../jwt'
6+
import { navigateToRoom } from '../router'
7+
import { ROLES, JWT_EXPIRY_SECONDS } from '../../constants'
8+
9+
/**
10+
* Room creation flow — triggered by "Start Session" button.
11+
*
12+
* Generates:
13+
* 1. RSA signing keypair → localStorage (JWT signing + nonce proof)
14+
* 2. RSA-OAEP keypair → localStorage (for receiving room key)
15+
* 3. AES-GCM room key → localStorage (encrypts notes + chat in Yjs)
16+
* 4. ownerId (UUID) → localStorage
17+
* 5. roomId (UUID)
18+
* 6. Self-issued owner JWT → URL fragment
19+
*
20+
* Public keys are NEVER placed in the URL.
21+
* Self-admission runs in useSession after YjsProvider is ready.
22+
*/
23+
export async function createRoom(): Promise<void> {
24+
const ownerId = crypto.randomUUID()
25+
const roomId = crypto.randomUUID()
26+
27+
const [signingKP, oaepKP, roomKey] = await Promise.all([
28+
generateSigningKeyPair(),
29+
generateOaepKeyPair(),
30+
generateRoomKey(),
31+
])
32+
33+
await Promise.all([
34+
saveSigningKeyPair(signingKP.privateKey, signingKP.publicKey, ownerId),
35+
saveOaepKeyPair(oaepKP.privateKey, oaepKP.publicKey, ownerId),
36+
saveRoomKey(roomKey, roomId),
37+
])
38+
savePeerId(ownerId)
39+
40+
const token = await mintJWT(
41+
{ room: roomId, role: ROLES.OWNER },
42+
ownerId,
43+
signingKP.privateKey,
44+
JWT_EXPIRY_SECONDS,
45+
)
46+
47+
navigateToRoom(token)
48+
}

0 commit comments

Comments
 (0)