Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions hub/src/api/api-keys.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import { Hono } from 'hono'
import { createApiKey, listApiKeys, revokeApiKey } from '../db/dal'
import { hashToken } from '../ws/channel'
import { generateSecureToken, hashToken } from '../lib/crypto'

const apiKeys = new Hono()

function generateApiKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
const b64 = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return `remokey_${b64}`
}

// List API keys (never returns raw key)
apiKeys.get('/', async (c) => {
const sb = c.get('supabase')
Expand All @@ -21,7 +14,7 @@ apiKeys.get('/', async (c) => {
// Generate new API key (revokes existing active key)
apiKeys.post('/', async (c) => {
const userId = c.get('userId')
const rawKey = generateApiKey()
const rawKey = generateSecureToken('remokey_')
const keyHash = await hashToken(rawKey)
const key = await createApiKey(userId, keyHash)
return c.json({ ...key, key: rawKey }, 201)
Expand Down
24 changes: 8 additions & 16 deletions hub/src/api/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { findOrCreateSession } from '../db/dal'
import { hashToken } from '../ws/channel'
import { generateSecureToken, hashToken } from '../lib/crypto'
import { getChannel } from '../ws/registry'
import { supabaseAdmin } from '../db/supabase'
import { TIER_LIMITS } from './profile'
import { TIER_LIMITS } from '../config'

const plugin = new Hono()

function generateToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
const b64 = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return `remo_${b64}`
}

const PluginSessionBody = z.object({
project_dir: z.string().min(1).max(500),
})
Expand Down Expand Up @@ -43,20 +36,19 @@ plugin.post('/sessions', async (c) => {

if (!existingSession) {
// New session — check tier limit
const { data: prof } = await supabaseAdmin.from('profiles').select('tier').eq('id', userId).single()
const [{ data: prof }, { count }] = await Promise.all([
supabaseAdmin.from('profiles').select('tier').eq('id', userId).single(),
supabaseAdmin.from('sessions').select('id', { count: 'exact', head: true }).eq('user_id', userId),
])
const tier = prof?.tier || 'free'
const limit = TIER_LIMITS[tier] || 1
const { count } = await supabaseAdmin
.from('sessions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)

if ((count || 0) >= limit) {
if (limit !== -1 && (count || 0) >= limit) {
return c.json({ error: 'session limit reached', tier, limit, current: count }, 403)
}
}

const rawToken = generateToken()
const rawToken = generateSecureToken('remo_')
const tokenHash = await hashToken(rawToken)
const result = await findOrCreateSession(userId, parsed.data.project_dir, tokenHash)

Expand Down
28 changes: 20 additions & 8 deletions hub/src/api/profile.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { supabaseAdmin } from '../db/supabase'
import { TIER_LIMITS } from '../config'

const profile = new Hono()

const TIER_LIMITS: Record<string, number> = {
free: 1,
pro: 10,
max: Infinity,
}

// Get current user's profile
profile.get('/', async (c) => {
const userId = c.get('userId')
Expand Down Expand Up @@ -50,7 +45,24 @@ profile.patch('/', async (c) => {
.eq('id', userId)

if (error) return c.json({ error: 'update failed' }, 500)
return c.json({ ok: true })

// Return the updated profile (same shape as GET)
const { data } = await supabaseAdmin
.from('profiles')
.select('id, email, display_name, role, tier, stripe_customer_id, created_at')
.eq('id', userId)
.single()

const { count } = await supabaseAdmin
.from('sessions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)

return c.json({
...data,
session_count: count || 0,
session_limit: TIER_LIMITS[data?.tier || 'free'] || 1,
})
})

export { profile, TIER_LIMITS }
export { profile }
35 changes: 10 additions & 25 deletions hub/src/api/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { createSession, listSessions, getSession, deleteSession, updateSessionToken } from '../db/dal'
import { hashToken } from '../ws/channel'
import { generateSecureToken, hashToken } from '../lib/crypto'
import { getChannel } from '../ws/registry'
import { supabaseAdmin } from '../db/supabase'
import { TIER_LIMITS } from './profile'
import { TIER_LIMITS } from '../config'

const CreateSessionBody = z.object({
name: z.string().min(1).max(100).trim(),
Expand All @@ -13,13 +13,6 @@ const CreateSessionBody = z.object({

const sessions = new Hono()

function generateToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
const b64 = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return `remo_${b64}`
}

// List all sessions for the authenticated user
sessions.get('/', async (c) => {
const sb = c.get('supabase')
Expand Down Expand Up @@ -48,26 +41,18 @@ sessions.post('/', async (c) => {
}

// Check session limit based on tier
const { data: prof } = await supabaseAdmin.from('profiles').select('tier').eq('id', userId).single()
const [{ data: prof }, { count }] = await Promise.all([
supabaseAdmin.from('profiles').select('tier').eq('id', userId).single(),
supabaseAdmin.from('sessions').select('id', { count: 'exact', head: true }).eq('user_id', userId),
])
const tier = prof?.tier || 'free'
const limit = TIER_LIMITS[tier] || 1

const { count } = await supabaseAdmin
.from('sessions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)

if ((count || 0) >= limit) {
return c.json({
error: 'session limit reached',
tier,
limit,
current: count,
upgrade_url: '/settings?tab=billing',
}, 403)
if (limit !== -1 && (count || 0) >= limit) {
return c.json({ error: 'session limit reached', tier, limit, current: count }, 403)
}

const rawToken = generateToken()
const rawToken = generateSecureToken('remo_')
const tokenHash = await hashToken(rawToken)

const session = await createSession(sb, userId, parsed.data.name, parsed.data.project_dir || null, tokenHash)
Expand Down Expand Up @@ -98,7 +83,7 @@ sessions.post('/:id/rotate-token', async (c) => {
return c.json({ error: 'not found' }, 404)
}

const rawToken = generateToken()
const rawToken = generateSecureToken('remo_')
const tokenHash = await hashToken(rawToken)
await updateSessionToken(sb, sessionId, tokenHash)

Expand Down
2 changes: 1 addition & 1 deletion hub/src/auth/api-key-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Context, Next } from 'hono'
import { verifyApiKey } from '../db/dal'
import { hashToken } from '../ws/channel'
import { hashToken } from '../lib/crypto'

export async function apiKeyMiddleware(c: Context, next: Next) {
const authHeader = c.req.header('Authorization')
Expand Down
7 changes: 7 additions & 0 deletions hub/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
// -1 = unlimited (JSON cannot represent Infinity)
export const TIER_LIMITS: Record<string, number> = {
free: 1,
pro: 10,
max: -1,
}

export const config = {
port: Number(process.env.PORT || 3040),
supabaseUrl: process.env.SUPABASE_URL!,
Expand Down
13 changes: 13 additions & 0 deletions hub/src/lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function generateSecureToken(prefix: string): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
const b64 = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return `${prefix}${b64}`
}

export async function hashToken(token: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(token)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('')
}
9 changes: 1 addition & 8 deletions hub/src/ws/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { timingSafeEqual } from 'crypto'
import { ChannelInbound } from './protocol'
import { verifyChannelToken, setSessionStatus, insertMessage } from '../db/dal'
import { registerChannel, unregisterChannel, broadcastToSubscribers, broadcastToUser } from './registry'
import { listSessions } from '../db/dal'
import { supabaseAdmin } from '../db/supabase'
import { hashToken } from '../lib/crypto'

const AUTH_TIMEOUT_MS = 5_000
const HEARTBEAT_INTERVAL_MS = 30_000
Expand Down Expand Up @@ -33,13 +33,6 @@ export function createChannelWsData(): ChannelWsData {
}
}

export async function hashToken(token: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(token)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('')
}

export function handleChannelOpen(ws: ServerWebSocket<ChannelWsData>) {
const data = ws.data
// Require auth within 5 seconds
Expand Down
62 changes: 21 additions & 41 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Layout } from './components/Layout'
import { SettingsPage } from './components/SettingsPage'
import { AdminDashboard } from './components/AdminDashboard'
import { PricingModal } from './components/PricingModal'
import { HUB_URL } from './lib/api'

type Route = 'chat' | 'settings' | 'admin'

Expand All @@ -17,6 +18,14 @@ function getRoute(): Route {
return 'chat'
}

function LoadingScreen() {
return (
<div className="flex items-center justify-center h-screen bg-slate-900">
<div className="text-slate-400">Loading...</div>
</div>
)
}

export default function App() {
const { session, user, loading, signOut } = useAuth()
const { profile, loading: profileLoading, updateProfile, isAdmin } = useProfile(session)
Expand All @@ -25,8 +34,7 @@ export default function App() {
const [showPricing, setShowPricing] = useState(false)

useEffect(() => {
const hubUrl = import.meta.env.VITE_HUB_URL || ''
fetch(`${hubUrl}/api/setup/status`)
fetch(`${HUB_URL}/api/setup/status`)
.then(r => r.json())
.then(data => setNeedsSetup(data.needs_setup))
.catch(() => setNeedsSetup(false))
Expand All @@ -43,34 +51,18 @@ export default function App() {
window.location.hash = hash
}, [])

const goToChat = useCallback(() => {
window.location.hash = '#/'
}, [])

if (loading || needsSetup === null) {
return (
<div className="flex items-center justify-center h-screen bg-slate-900">
<div className="text-slate-400">Loading...</div>
</div>
)
}
if (loading || needsSetup === null) return <LoadingScreen />

if (needsSetup) {
return <SetupForm onComplete={() => setNeedsSetup(false)} />
}

if (!session || !user) {
return <AuthForm />
}
if (!session || !user) return <AuthForm />

// Wait for profile to load before rendering gated routes
if (profileLoading || !profile) {
return (
<div className="flex items-center justify-center h-screen bg-slate-900">
<div className="text-slate-400">Loading...</div>
</div>
)
}
if (profileLoading || !profile) return <LoadingScreen />

// Non-admin on admin route falls through to chat
const effectiveRoute = (route === 'admin' && !isAdmin) ? 'chat' : route

return (
<>
Expand All @@ -82,36 +74,24 @@ export default function App() {
/>
)}

{route === 'settings' && (
{effectiveRoute === 'settings' && (
<SettingsPage
session={session}
profile={profile}
onUpdateProfile={updateProfile}
onBack={goToChat}
onBack={() => navigate('#/')}
onShowPricing={() => setShowPricing(true)}
/>
)}

{route === 'admin' && isAdmin && (
{effectiveRoute === 'admin' && (
<AdminDashboard
session={session}
onBack={goToChat}
/>
)}

{/* Redirect non-admin from admin route */}
{route === 'admin' && !isAdmin && (
<Layout
session={session}
user={user}
signOut={signOut}
profile={profile}
onNavigate={navigate}
onShowPricing={() => setShowPricing(true)}
onBack={() => navigate('#/')}
/>
)}

{route === 'chat' && (
{effectiveRoute === 'chat' && (
<Layout
session={session}
user={user}
Expand Down
20 changes: 11 additions & 9 deletions web/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@ export function Layout({ session, user, signOut, profile, onNavigate, onShowPric
)

// Listen for session status updates
subscribe((msg) => {
if (msg.type === 'session_status') {
sessionsHook.updateSessionStatus(msg.session_id, msg.status)
}
if (msg.type === 'session_list' && msg.sessions) {
sessionsHook.setSessions(msg.sessions)
}
})
useEffect(() => {
return subscribe((msg) => {
if (msg.type === 'session_status') {
sessionsHook.updateSessionStatus(msg.session_id, msg.status)
}
if (msg.type === 'session_list' && msg.sessions) {
sessionsHook.setSessions(msg.sessions)
}
})
}, [subscribe, sessionsHook.updateSessionStatus, sessionsHook.setSessions])

// Wrap createSession to detect 403 session limit
const handleCreateSession = useCallback(async (name: string, projectDir?: string) => {
Expand Down Expand Up @@ -113,7 +115,7 @@ export function Layout({ session, user, signOut, profile, onNavigate, onShowPric
onShowConnect={setConnectData}
onShowApiKey={() => setShowApiKey(true)}
onNavigate={onNavigate}
isAdmin={profile.role === 'admin'}
isAdmin={profile?.role === 'admin'}
connected={connected}
user={user}
signOut={signOut}
Expand Down
Loading