Compare commits

...

10 commits

76 changed files with 1852 additions and 109 deletions

View file

@ -1 +1 @@
{"projects":{"default":"byteclaw-app"}} {"projects":{"default":"byteclaw-app-65807"}}

4
.gitignore vendored
View file

@ -4,3 +4,7 @@
*.db *.db
config.yaml config.yaml
.env .env
.kotlin/
.netlify/
.firebase/

View file

@ -20,7 +20,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github.
## Sichere Defaults ## Sichere Defaults
- Start verweigert ohne `auth_token` (mind. 16 Zeichen); Vergleich in konstanter Zeit. - Konten: `POST /api/auth/register|login|logout`, `GET /api/auth/me`; Passwörter als bcrypt-Hash, Tokens zufällig + gehasht gespeichert, 30 Tage gültig, Logout widerruft. Sessions sind pro Nutzer getrennt. Login-Rate-Limit pro IP. `registration_mode`: `open` | `invite` | `closed` (ohne Angabe gilt `allow_registration`). Im Modus `invite` braucht jede Registrierung einen Einladungscode (`BC-XXXX-XXXX`), den nur der Admin erstellt: `POST /api/invites` (`max_uses`, Standard 1; `expires_in_hours`, optional), `GET /api/invites`, `DELETE /api/invites/{code}`. Der Code wird in derselben Transaktion wie das Konto verbraucht. Öffentlich: `GET /api/auth/config` liefert den Modus. Optionaler statischer `auth_token` (mind. 16 Zeichen) = Admin, sieht alle Sessions; Vergleich in konstanter Zeit.
- Lauscht standardmäßig nur auf `127.0.0.1:8787`. - Lauscht standardmäßig nur auf `127.0.0.1:8787`.
- Browser-Origins: nur gleicher Host oder explizit in `allowed_origins`; `*` muss bewusst gesetzt werden. Native Clients (ohne `Origin`-Header) brauchen trotzdem das Token. - Browser-Origins: nur gleicher Host oder explizit in `allowed_origins`; `*` muss bewusst gesetzt werden. Native Clients (ohne `Origin`-Header) brauchen trotzdem das Token.
- TLS-Zertifikatsprüfung ist überall an (LLM-Provider, `web_fetch`); es gibt keinen Schalter zum Abschalten. - TLS-Zertifikatsprüfung ist überall an (LLM-Provider, `web_fetch`); es gibt keinen Schalter zum Abschalten.
@ -164,6 +164,8 @@ Adaptive Icon als Vektor (`res/drawable/ic_launcher_{background,foreground,monoc
In der App unter Einstellungen Server-URL (`https://…`; im Debug-Build auch `http://10.0.2.2:8787` für den Emulator) und Token eintragen, „Verbindung testen", speichern. In der App unter Einstellungen Server-URL (`https://…`; im Debug-Build auch `http://10.0.2.2:8787` für den Emulator) und Token eintragen, „Verbindung testen", speichern.
Öffentlicher Server: Die App schlägt standardmäßig `https://byteclaw.jodabreit-ai.de` vor (Registrierung offen). Hinter einem Reverse-Proxy dessen IP/CIDR in `trusted_proxies` eintragen, damit das Login-Rate-Limit die echte Client-IP aus `X-Forwarded-For` nutzt.
## Nächste Schritte ## Nächste Schritte
- CI (GitHub Actions) für Go + Android - CI (GitHub Actions) für Go + Android

View file

@ -31,8 +31,8 @@ android {
applicationId = "dev.byteclaw.client" applicationId = "dev.byteclaw.client"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 2 versionCode = 5
versionName = "0.2.0" versionName = "0.3.2"
} }
signingConfigs { signingConfigs {

View file

@ -26,6 +26,7 @@ import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.ChatViewModel import dev.byteclaw.client.ui.ChatViewModel
import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.LoginScreen
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var voiceCallback: ((String) -> Unit)? = null private var voiceCallback: ((String) -> Unit)? = null
@ -61,7 +62,16 @@ class MainActivity : ComponentActivity() {
val vm: ChatViewModel = viewModel() val vm: ChatViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val nav = rememberNavController() val nav = rememberNavController()
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") { val loggedIn = state.settings.isConfigured
androidx.compose.runtime.LaunchedEffect(loggedIn) {
val target = if (loggedIn) "chat" else "login"
if (nav.currentDestination?.route != target) nav.navigate(target) { popUpTo(0) { inclusive = true } }
}
NavHost(nav, startDestination = if (loggedIn) "chat" else "login") {
composable("login") {
LoginScreen(state, onAuthenticate = { u, n, p, r, i -> vm.authenticate(u, n, p, r, i) }, onCheckMode = vm::checkRegistrationMode,
onAdminToken = { u, t -> vm.useAdminToken(u, t) })
}
composable("chat") { composable("chat") {
androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() } androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() }
ChatScreen( ChatScreen(
@ -83,7 +93,8 @@ class MainActivity : ComponentActivity() {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = { SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } } if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) }, }, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
onSelectProvider = { p, m -> vm.selectProvider(p, m) }) onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout,
onLoadInvites = { vm.loadInvites() }, onCreateInvite = { n, h -> vm.createInvite(n, h) }, onRevokeInvite = { vm.revokeInvite(it) })
} }
} }
} }

View file

@ -17,7 +17,10 @@ import okhttp3.WebSocketListener
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ApiException(msg: String) : IOException(msg) open class ApiException(msg: String, val code: Int = 0) : IOException(msg)
/** Token missing, expired or revoked: the app returns to the login screen. */
class UnauthorizedException : ApiException("Sitzung abgelaufen – bitte erneut anmelden", 401)
/** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */ /** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */
class ByteClawApi(private val settings: ServerSettings) { class ByteClawApi(private val settings: ServerSettings) {
@ -33,7 +36,8 @@ class ByteClawApi(private val settings: ServerSettings) {
private suspend fun call(req: Request): String = withContext(Dispatchers.IO) { private suspend fun call(req: Request): String = withContext(Dispatchers.IO) {
http.newCall(req).execute().use { r: Response -> http.newCall(req).execute().use { r: Response ->
val body = r.body?.string().orEmpty() val body = r.body?.string().orEmpty()
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}") if (r.code == 401) throw UnauthorizedException()
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}", r.code)
body body
} }
} }
@ -42,6 +46,13 @@ class ByteClawApi(private val settings: ServerSettings) {
http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful } http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful }
} }
suspend fun me(): AuthUser = json.decodeFromString(call(Request.Builder().url(url("/api/auth/me")).build()))
/** Revokes the current token on the server. */
suspend fun logout() {
call(Request.Builder().url(url("/api/auth/logout")).post(ByteArray(0).toRequestBody(null)).build())
}
suspend fun info(): ServerInfo = json.decodeFromString(call(Request.Builder().url(url("/api/info")).build())) suspend fun info(): ServerInfo = json.decodeFromString(call(Request.Builder().url(url("/api/info")).build()))
suspend fun sessions(): List<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build())) suspend fun sessions(): List<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build()))
@ -49,6 +60,18 @@ class ByteClawApi(private val settings: ServerSettings) {
suspend fun messages(sessionId: String): List<Message> = suspend fun messages(sessionId: String): List<Message> =
json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build())) json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build()))
suspend fun invites(): List<Invite> =
json.decodeFromString(call(Request.Builder().url(url("/api/invites")).build()))
suspend fun createInvite(maxUses: Int, expiresInHours: Int): Invite {
val body = json.encodeToString(InviteRequest.serializer(), InviteRequest(maxUses, expiresInHours)).toRequestBody(JSON)
return json.decodeFromString(call(Request.Builder().url(url("/api/invites")).post(body).build()))
}
suspend fun deleteInvite(code: String) {
call(Request.Builder().url(url("/api/invites/$code")).delete().build())
}
suspend fun deleteSession(sessionId: String) { suspend fun deleteSession(sessionId: String) {
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build()) call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
} }
@ -118,6 +141,34 @@ class ByteClawApi(private val settings: ServerSettings) {
} }
companion object { companion object {
private val authJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
/** POST /api/auth/login or /register without a bearer token. Errors carry German text. */
suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean, inviteCode: String = ""): AuthResponse =
withContext(Dispatchers.IO) {
val path = if (register) "/api/auth/register" else "/api/auth/login"
val creds = Credentials(username.trim(), password, inviteCode.trim().uppercase().takeIf { register && it.isNotBlank() })
val body = authJson.encodeToString(Credentials.serializer(), creds).toRequestBody(JSON)
val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + path).toHttpUrl()).post(body).build()
shared.newCall(req).execute().use { r ->
val text = r.body?.string().orEmpty()
if (!r.isSuccessful) throw ApiException(AuthErrors.german(r.code, text), r.code)
authJson.decodeFromString(AuthResponse.serializer(), text)
}
}
/** Registration mode of a server; older servers without the endpoint count as "open". */
suspend fun registrationMode(serverUrl: String): String = withContext(Dispatchers.IO) {
runCatching {
val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + "/api/auth/config").toHttpUrl()).build()
shared.newCall(req).execute().use { r ->
if (r.code == 404) "open"
else if (!r.isSuccessful) null
else authJson.decodeFromString(AuthConfig.serializer(), r.body?.string().orEmpty()).registrationMode
}
}.getOrNull() ?: "unknown"
}
private val JSON = "application/json".toMediaType() private val JSON = "application/json".toMediaType()
private val shared = OkHttpClient.Builder() private val shared = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)

View file

@ -92,3 +92,59 @@ object ProviderPresets {
@Serializable @Serializable
data class RenameRequest(val title: String) data class RenameRequest(val title: String)
@Serializable
data class Credentials(
val username: String,
val password: String,
@SerialName("invite_code") val inviteCode: String? = null,
)
/** GET /api/auth/config (public): "open", "invite" or "closed". */
@Serializable
data class AuthConfig(@SerialName("registration_mode") val registrationMode: String = "open")
/** Invite code (admin only). */
@Serializable
data class Invite(
val code: String = "",
@SerialName("max_uses") val maxUses: Int = 1,
val uses: Int = 0,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("used_by") val usedBy: List<String> = emptyList(),
)
@Serializable
data class InviteRequest(@SerialName("max_uses") val maxUses: Int = 1, @SerialName("expires_in_hours") val expiresInHours: Int = 0)
@Serializable
data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false)
@Serializable
data class AuthResponse(val token: String, val user: AuthUser = AuthUser())
/** Maps server auth errors to German UI text. */
object AuthErrors {
fun german(code: Int, body: String): String = when {
code == 401 -> "Benutzername oder Passwort falsch"
code == 409 -> "Benutzername ist bereits vergeben"
code == 400 && body.contains("invite code required") -> "Einladungscode erforderlich"
code == 403 && body.contains("invalid invite") -> "Einladungscode ist ungültig"
code == 403 && body.contains("already used") -> "Einladungscode wurde bereits verwendet"
code == 403 && body.contains("expired") -> "Einladungscode ist abgelaufen"
code == 403 -> "Registrierung ist auf diesem Server deaktiviert"
code == 429 -> "Zu viele Versuche – bitte später erneut probieren"
code == 400 && body.contains("username") -> "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)"
code == 400 && body.contains("password") -> "Passwort: mindestens 8 Zeichen"
else -> "Fehler (HTTP $code)"
}
/** Client-side check before contacting the server; null = ok. */
fun validate(username: String, password: String): String? {
val u = username.trim()
if (u.length !in 3..32 || !u.all { it.isLetterOrDigit() && it.code < 128 || it in "._-" }) return "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)"
if (password.length < 8) return "Passwort: mindestens 8 Zeichen"
return null
}
}

View file

@ -5,7 +5,7 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
data class ServerSettings(val serverUrl: String = "", val token: String = "") { data class ServerSettings(val serverUrl: String = "", val token: String = "", val username: String = "") {
val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank() val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank()
} }
@ -20,16 +20,21 @@ class SettingsStore(context: Context) {
) )
fun load() = ServerSettings( fun load() = ServerSettings(
serverUrl = prefs.getString(KEY_URL, "") ?: "", serverUrl = prefs.getString(KEY_URL, null)?.takeIf { it.isNotBlank() } ?: DEFAULT_SERVER_URL,
token = prefs.getString(KEY_TOKEN, "") ?: "", token = prefs.getString(KEY_TOKEN, "") ?: "",
username = prefs.getString(KEY_USER, "") ?: "",
) )
fun save(s: ServerSettings) { fun save(s: ServerSettings) {
prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim()).apply() prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim())
.putString(KEY_USER, s.username.trim()).apply()
} }
private companion object { companion object {
const val KEY_URL = "server_url" /** Public ByteClaw server, prefilled on the login screen (user can change it). */
const val KEY_TOKEN = "token" const val DEFAULT_SERVER_URL = "https://byteclaw.jodabreit-ai.de"
private const val KEY_URL = "server_url"
private const val KEY_TOKEN = "token"
private const val KEY_USER = "username"
} }
} }

View file

@ -4,7 +4,10 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.byteclaw.client.data.ActiveProvider import dev.byteclaw.client.data.ActiveProvider
import dev.byteclaw.client.data.Invite
import dev.byteclaw.client.data.ByteClawApi import dev.byteclaw.client.data.ByteClawApi
import dev.byteclaw.client.data.AuthErrors
import dev.byteclaw.client.data.UnauthorizedException
import dev.byteclaw.client.data.ProviderInfo import dev.byteclaw.client.data.ProviderInfo
import dev.byteclaw.client.data.ProviderPresets import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ServerSettings import dev.byteclaw.client.data.ServerSettings
@ -39,6 +42,16 @@ data class UiState(
val sessionProvider: ActiveProvider? = null, val sessionProvider: ActiveProvider? = null,
/** null = unknown, true/false = last health check result. */ /** null = unknown, true/false = last health check result. */
val online: Boolean? = null, val online: Boolean? = null,
/** Login screen state. */
val authBusy: Boolean = false,
val authError: String? = null,
/** From /api/auth/me: only the admin may change providers. */
val isAdmin: Boolean = false,
/** Registration mode of the server entered on the login screen: open, invite, closed or unknown. */
val registrationMode: String = "unknown",
/** Admin: invite codes and status of the last invite action. */
val invites: List<Invite> = emptyList(),
val inviteStatus: String? = null,
) { ) {
/** What the current chat effectively uses: session override, else the global active provider. */ /** What the current chat effectively uses: session override, else the global active provider. */
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
@ -73,6 +86,81 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
_state.update { it.copy(settings = store.load(), error = null) } _state.update { it.copy(settings = store.load(), error = null) }
} }
/** Login or registration; on success the token is stored encrypted and the chat opens. */
fun checkRegistrationMode(serverUrl: String) {
if (ByteClawApi.validateUrl(serverUrl) != null) return
viewModelScope.launch {
val m = ByteClawApi.registrationMode(serverUrl)
_state.update { it.copy(registrationMode = m) }
}
}
fun loadInvites() = viewModelScope.launch {
runCatching { api().invites() }
.onSuccess { l -> _state.update { it.copy(invites = l) } }
.onFailure { e -> _state.update { it.copy(inviteStatus = "Codes laden fehlgeschlagen: ${e.message}") } }
}
fun createInvite(maxUses: Int, expiresInHours: Int) = viewModelScope.launch {
runCatching { api().createInvite(maxUses, expiresInHours) }
.onSuccess { i -> _state.update { it.copy(inviteStatus = "Neuer Code: ${i.code}") }; loadInvites() }
.onFailure { e -> _state.update { it.copy(inviteStatus = "Fehler: ${e.message}") } }
}
fun revokeInvite(code: String) = viewModelScope.launch {
runCatching { api().deleteInvite(code) }
.onSuccess { _state.update { it.copy(inviteStatus = "Code $code widerrufen") }; loadInvites() }
.onFailure { e -> _state.update { it.copy(inviteStatus = "Fehler: ${e.message}") } }
}
fun authenticate(serverUrl: String, username: String, password: String, register: Boolean, inviteCode: String = "") {
val urlErr = ByteClawApi.validateUrl(serverUrl)
val err = urlErr ?: AuthErrors.validate(username, password)
?: if (register && _state.value.registrationMode == "invite" && inviteCode.isBlank()) "Einladungscode erforderlich" else null
if (err != null) { _state.update { it.copy(authError = err) }; return }
_state.update { it.copy(authBusy = true, authError = null) }
viewModelScope.launch {
runCatching { ByteClawApi.authenticate(serverUrl, username, password, register, inviteCode) }
.onSuccess { r ->
store.save(ServerSettings(serverUrl, r.token, r.user.username))
_state.update { UiState(settings = store.load()) }
}
.onFailure { e -> _state.update { it.copy(authBusy = false, authError = e.message ?: "Verbindung fehlgeschlagen") } }
}
}
/** Back-compat: sign in with the server's static admin token. */
fun useAdminToken(serverUrl: String, token: String) {
val err = ByteClawApi.validateUrl(serverUrl) ?: if (token.trim().length < 16) "Token: mindestens 16 Zeichen" else null
if (err != null) { _state.update { it.copy(authError = err) }; return }
_state.update { it.copy(authBusy = true, authError = null) }
viewModelScope.launch {
val s = ServerSettings(serverUrl, token, "admin")
runCatching { ByteClawApi(s).me() }
.onSuccess { u -> store.save(s.copy(username = u.username)); _state.update { UiState(settings = store.load()) } }
.onFailure { e -> _state.update { it.copy(authBusy = false, authError = if (e is UnauthorizedException) "Token ungültig" else e.message) } }
}
}
fun logout() {
val s = _state.value.settings
job?.cancel()
viewModelScope.launch { runCatching { ByteClawApi(s).logout() } }
clearSession(null)
}
/** Drops the token (keeps URL + username for the login form). */
private fun clearSession(reason: String?) {
val s = _state.value.settings
store.save(s.copy(token = ""))
_state.update { UiState(settings = store.load(), authError = reason) }
}
/** Central error handler: 401 sends the user back to the login screen. */
private fun fail(e: Throwable) {
if (e is UnauthorizedException) clearSession(e.message) else _state.update { it.copy(error = e.message) }
}
fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch { fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch {
val msg = runCatching { val msg = runCatching {
val i = ByteClawApi(s).info() val i = ByteClawApi(s).info()
@ -91,7 +179,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
fun refreshSessions() = viewModelScope.launch { fun refreshSessions() = viewModelScope.launch {
runCatching { api().sessions() } runCatching { api().sessions() }
.onSuccess { list -> _state.update { it.copy(sessions = list) } } .onSuccess { list -> _state.update { it.copy(sessions = list) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } } .onFailure { e -> fail(e) }
} }
fun openSession(id: String) = viewModelScope.launch { fun openSession(id: String) = viewModelScope.launch {
@ -113,7 +201,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
_state.update { it.copy(sessionProvider = ss.provider.takeIf { p -> p.isNotBlank() }?.let { p -> ActiveProvider(p, ss.model) }) } _state.update { it.copy(sessionProvider = ss.provider.takeIf { p -> p.isNotBlank() }?.let { p -> ActiveProvider(p, ss.model) }) }
} }
} }
.onFailure { e -> _state.update { it.copy(error = e.message) } } .onFailure { e -> fail(e) }
} }
fun deleteSession(id: String) = viewModelScope.launch { fun deleteSession(id: String) = viewModelScope.launch {
@ -122,7 +210,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
if (_state.value.sessionId == id) newChat() if (_state.value.sessionId == id) newChat()
refreshSessions() refreshSessions()
} }
.onFailure { e -> _state.update { it.copy(error = e.message) } } .onFailure { e -> fail(e) }
} }
fun send(text: String) { fun send(text: String) {
@ -148,7 +236,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
System.currentTimeMillis() - started) System.currentTimeMillis() - started)
} }
"cancelled" -> markCancelled() "cancelled" -> markCancelled()
"error" -> _state.update { s -> "error" -> if (ev.text == "HTTP 401") clearSession("Sitzung abgelaufen – bitte erneut anmelden") else _state.update { s ->
s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
} }
} }
@ -171,7 +259,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
if (t.isEmpty()) return@launch if (t.isEmpty()) return@launch
runCatching { api().renameSession(id, t) } runCatching { api().renameSession(id, t) }
.onSuccess { _state.update { s -> s.copy(sessions = s.sessions.map { if (it.id == id) it.copy(title = t) else it }) } } .onSuccess { _state.update { s -> s.copy(sessions = s.sessions.map { if (it.id == id) it.copy(title = t) else it }) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } } .onFailure { e -> fail(e) }
} }
/** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */ /** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */
@ -208,14 +296,15 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { viewModelScope.launch {
runCatching { api().setSessionProvider(sid, name, model) } runCatching { api().setSessionProvider(sid, name, model) }
.onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } } .onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } } .onFailure { e -> fail(e) }
} }
} }
fun loadProviders() = viewModelScope.launch { fun loadProviders() = viewModelScope.launch {
runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } }
runCatching { api().providers() } runCatching { api().providers() }
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } } .onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
.onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } } .onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }
} }
fun loadModels(name: String) = viewModelScope.launch { fun loadModels(name: String) = viewModelScope.launch {

View file

@ -0,0 +1,86 @@
package dev.byteclaw.client.ui
import android.content.Intent
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import dev.byteclaw.client.data.Invite
private val expiryOptions = listOf(0 to "Nie", 24 to "1 Tag", 24 * 7 to "7 Tage", 24 * 30 to "30 Tage")
/** Admin only: create, copy/share, list and revoke invite codes. */
@Composable
fun InviteSection(
state: UiState,
onLoad: () -> Unit,
onCreate: (maxUses: Int, expiresInHours: Int) -> Unit,
onRevoke: (String) -> Unit,
) {
LaunchedEffect(Unit) { onLoad() }
var maxUses by remember { mutableStateOf("1") }
var expiry by remember { mutableIntStateOf(0) }
var confirm by remember { mutableStateOf<String?>(null) }
val clip = LocalClipboardManager.current
val ctx = LocalContext.current
fun share(code: String) {
val text = "Einladung zu ByteClaw: ${state.settings.serverUrl}\nEinladungscode: $code"
val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text)
runCatching { ctx.startActivity(Intent.createChooser(send, null)) }
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Einladungscodes", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
TextButton(onClick = onLoad) { Text("Aktualisieren") }
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(maxUses, { v -> maxUses = v.filter { it.isDigit() }.take(4) }, Modifier.width(110.dp),
label = { Text("Nutzungen") }, singleLine = true)
var open by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { open = true }) { Text("Gültig: " + expiryOptions.first { it.first == expiry }.second) }
DropdownMenu(open, { open = false }) {
expiryOptions.forEach { (h, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { expiry = h; open = false }) }
}
}
}
Button(onClick = { onCreate((maxUses.toIntOrNull() ?: 1).coerceIn(1, 1000), expiry) }) { Text("Code erstellen") }
state.inviteStatus?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
if (state.invites.isEmpty()) Text("Noch keine Codes.", style = MaterialTheme.typography.bodySmall)
state.invites.forEach { inv -> InviteRow(inv, onCopy = { clip.setText(AnnotatedString(inv.code)) }, onShare = { share(inv.code) }, onRevoke = { confirm = inv.code }) }
}
}
confirm?.let { code ->
AlertDialog(onDismissRequest = { confirm = null }, title = { Text("Code widerrufen?") },
text = { Text("$code kann danach nicht mehr verwendet werden. Bereits registrierte Konten bleiben bestehen.") },
confirmButton = { TextButton(onClick = { onRevoke(code); confirm = null }) { Text("Widerrufen") } },
dismissButton = { TextButton(onClick = { confirm = null }) { Text("Abbrechen") } })
}
}
@Composable
private fun InviteRow(inv: Invite, onCopy: () -> Unit, onShare: () -> Unit, onRevoke: () -> Unit) {
val usedUp = inv.uses >= inv.maxUses
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(inv.code, fontFamily = FontFamily.Monospace, color = if (usedUp) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface)
val exp = inv.expiresAt?.take(10)?.let { " · bis $it" } ?: ""
val by = if (inv.usedBy.isNotEmpty()) " · " + inv.usedBy.joinToString() else ""
Text("${inv.uses}/${inv.maxUses} genutzt$exp$by", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
}
IconButton(onClick = onCopy) { Icon(Icons.Filled.ContentCopy, "Kopieren") }
IconButton(onClick = onShare, enabled = !usedUp) { Icon(Icons.Filled.Share, "Teilen") }
IconButton(onClick = onRevoke) { Icon(Icons.Filled.Delete, "Widerrufen") }
}
}

View file

@ -0,0 +1,99 @@
package dev.byteclaw.client.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import dev.byteclaw.client.data.ByteClawApi
/** Shown whenever no valid token is stored: sign in, sign up, or (advanced) use the static admin token. */
@Composable
fun LoginScreen(
state: UiState,
onAuthenticate: (url: String, user: String, pass: String, register: Boolean, invite: String) -> Unit,
onCheckMode: (url: String) -> Unit = {},
onAdminToken: (url: String, token: String) -> Unit = { _, _ -> },
initialRegister: Boolean = false,
initialUser: String? = null,
) {
var register by remember { mutableStateOf(initialRegister) }
var url by remember { mutableStateOf(state.settings.serverUrl) }
var user by remember { mutableStateOf(initialUser ?: state.settings.username) }
var pass by remember { mutableStateOf("") }
var pass2 by remember { mutableStateOf("") }
var adminMode by remember { mutableStateOf(false) }
var token by remember { mutableStateOf("") }
var invite by remember { mutableStateOf("") }
LaunchedEffect(url, register) { if (register) { kotlinx.coroutines.delay(400); onCheckMode(url) } }
val needInvite = register && state.registrationMode == "invite"
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
val mismatch = register && pass2.isNotEmpty() && pass != pass2
Scaffold { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(24.dp))
Text("ByteClaw", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
Text(if (register) "Neues Konto erstellen" else "Bei deinem Server anmelden", style = MaterialTheme.typography.bodyLarge)
if (!adminMode) {
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!register, { register = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Anmelden") }
SegmentedButton(register, { register = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Registrieren") }
}
}
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
placeholder = { Text("https://claw.example.org") }, singleLine = true,
isError = urlError != null, supportingText = urlError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
if (adminMode) {
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Admin-Token") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
} else {
OutlinedTextField(user, { user = it }, Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true,
supportingText = if (register) { { Text("3–32 Zeichen: Buchstaben, Ziffern, . _ -") } } else null)
OutlinedTextField(pass, { pass = it }, Modifier.fillMaxWidth(), label = { Text("Passwort") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
supportingText = if (register) { { Text("Mindestens 8 Zeichen") } } else null)
if (register) {
OutlinedTextField(pass2, { pass2 = it }, Modifier.fillMaxWidth(), label = { Text("Passwort wiederholen") }, singleLine = true,
isError = mismatch, supportingText = if (mismatch) { { Text("Passwörter stimmen nicht überein") } } else null,
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
if (state.registrationMode == "closed") {
Text("Registrierung ist auf diesem Server deaktiviert.", color = MaterialTheme.colorScheme.error)
}
if (needInvite) {
OutlinedTextField(invite, { invite = it.uppercase() }, Modifier.fillMaxWidth(), label = { Text("Einladungscode") },
placeholder = { Text("BC-XXXX-XXXX") }, singleLine = true,
supportingText = { Text("Diesen Server kann man nur mit Einladung nutzen – frag den Admin nach einem Code.") })
}
}
}
state.authError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
modifier = Modifier.fillMaxWidth(),
enabled = !state.authBusy && url.isNotBlank() && urlError == null &&
(if (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2 && (!needInvite || invite.isNotBlank()))),
onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register, invite) },
) {
if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" })
}
TextButton(onClick = { adminMode = !adminMode }) {
Text(if (adminMode) "Zurück zur Anmeldung" else "Stattdessen Admin-Token verwenden")
}
Text("Das Login-Token wird verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.",
style = MaterialTheme.typography.bodySmall)
}
}
}

View file

@ -270,11 +270,12 @@ fun SettingsScreen(
onLoadModels: (String) -> Unit = {}, onLoadModels: (String) -> Unit = {},
onSelectProvider: (String, String) -> Unit = { _, _ -> }, onSelectProvider: (String, String) -> Unit = { _, _ -> },
initialStatus: String? = null, initialStatus: String? = null,
onLogout: () -> Unit = {},
onLoadInvites: () -> Unit = {},
onCreateInvite: (Int, Int) -> Unit = { _, _ -> },
onRevokeInvite: (String) -> Unit = {},
) { ) {
var url by remember { mutableStateOf(state.settings.serverUrl) }
var token by remember { mutableStateOf(state.settings.token) }
var status by remember { mutableStateOf(initialStatus) } var status by remember { mutableStateOf(initialStatus) }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() } LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") }, TopAppBar(title = { Text("Einstellungen") },
@ -282,33 +283,31 @@ fun SettingsScreen(
}) { pad -> }) { pad ->
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { item {
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") }, Card(Modifier.fillMaxWidth()) {
placeholder = { Text("https://claw.example.org") }, singleLine = true, Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
isError = urlError != null, supportingText = { urlError?.let { Text(it) } }, Text("Konto", style = MaterialTheme.typography.titleMedium)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) Text("Angemeldet als ${state.settings.username.ifBlank { "unbekannt" }}")
} Text("Server: ${state.settings.serverUrl}", style = MaterialTheme.typography.bodySmall)
item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true, OutlinedButton(onClick = {
visualTransformation = PasswordVisualTransformation(), status = "Teste…"; onTest(state.settings) { status = it }
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) }) { Text("Verbindung testen") }
} Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Abmelden") }
item { }
Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.", }
style = MaterialTheme.typography.bodySmall)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
onSave(ServerSettings(url, token)); onBack()
}) { Text("Speichern") }
OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = {
status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
}) { Text("Verbindung testen") }
} }
} }
status?.let { item { Text(it) } } status?.let { item { Text(it) } }
if (state.settings.isConfigured) { if (state.settings.isConfigured) {
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } if (state.isAdmin) {
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
item { InviteSection(state, onLoadInvites, onCreateInvite, onRevokeInvite) }
} else {
item {
Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") +
"\nNur der Server-Admin kann den Provider ändern.", style = MaterialTheme.typography.bodySmall)
}
}
} }
} }
} }
@ -382,7 +381,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia
Box { Box {
AssistChip( AssistChip(
onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) }, onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) },
enabled = state.settings.isConfigured && !state.busy, enabled = state.settings.isConfigured && !state.busy && state.isAdmin,
modifier = Modifier.height(28.dp).widthIn(max = 230.dp), modifier = Modifier.height(28.dp).widthIn(max = 230.dp),
) )
DropdownMenu(expanded = open, onDismissRequest = { open = false }) { DropdownMenu(expanded = open, onDismissRequest = { open = false }) {

View file

@ -0,0 +1,36 @@
package dev.byteclaw.client
import dev.byteclaw.client.data.AuthErrors
import dev.byteclaw.client.data.AuthResponse
import dev.byteclaw.client.data.ServerSettings
import kotlinx.serialization.json.Json
import org.junit.Assert.*
import org.junit.Test
class AuthTest {
@Test fun validation() {
assertNotNull(AuthErrors.validate("ab", "longenough"))
assertNotNull(AuthErrors.validate("a".repeat(33), "longenough"))
assertNotNull(AuthErrors.validate("bad name", "longenough"))
assertNotNull(AuthErrors.validate("bob", "short"))
assertNull(AuthErrors.validate("crypto_1.x", "longenough"))
}
@Test fun germanErrors() {
assertEquals("Benutzername oder Passwort falsch", AuthErrors.german(401, ""))
assertEquals("Benutzername ist bereits vergeben", AuthErrors.german(409, ""))
assertTrue(AuthErrors.german(429, "").startsWith("Zu viele"))
assertTrue(AuthErrors.german(400, "{\"error\":\"password must be\"}").startsWith("Passwort"))
}
@Test fun parsesAuthResponse() {
val r = Json { ignoreUnknownKeys = true }.decodeFromString(AuthResponse.serializer(),
"""{"token":"bc_x","expires_at":"2026-12-01T00:00:00Z","user":{"id":2,"username":"alice"}}""")
assertEquals("bc_x", r.token); assertEquals("alice", r.user.username)
}
@Test fun configuredNeedsToken() {
assertFalse(ServerSettings("https://x", "", "alice").isConfigured)
assertTrue(ServerSettings("https://x", "bc_x", "alice").isConfigured)
}
}

View file

@ -10,6 +10,8 @@ import dev.byteclaw.client.ui.ChatLine
import dev.byteclaw.client.ui.ChatScreen import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.LoginScreen
import dev.byteclaw.client.ui.InviteSection
import dev.byteclaw.client.ui.UiState import dev.byteclaw.client.ui.UiState
import dev.byteclaw.client.ui.ProviderUi import dev.byteclaw.client.ui.ProviderUi
import dev.byteclaw.client.data.ProviderInfo import dev.byteclaw.client.data.ProviderInfo
@ -35,14 +37,14 @@ class PreviewScreenshots(private val dark: Boolean) {
maxPercentDifference = 0.1, maxPercentDifference = 0.1,
) )
private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real") private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real", "crypto")
private fun snap(name: String, content: @Composable () -> Unit) = private fun snap(name: String, content: @Composable () -> Unit) =
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) } paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) }
@Test fun chat() = snap("chat") { @Test fun chat() = snap("chat") {
ChatScreen( ChatScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, sessionId = "demo", busy = true, settings = settings, sessionId = "demo", busy = true,
lines = listOf( lines = listOf(
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"), ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
@ -67,7 +69,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chatProvider() = snap("chat_provider") { @Test fun chatProvider() = snap("chat_provider") {
ChatScreen( ChatScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, sessionId = "demo", provider = demoProviders, settings = settings, sessionId = "demo", provider = demoProviders,
sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"), sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"),
lines = listOf( lines = listOf(
@ -82,7 +84,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chatMarkdown() = snap("chat_markdown") { @Test fun chatMarkdown() = snap("chat_markdown") {
ChatScreen( ChatScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, sessionId = "demo", online = true, settings = settings, sessionId = "demo", online = true,
lines = listOf( lines = listOf(
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"), ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
@ -95,7 +97,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun sessionsSearch() = snap("sessions_search") { @Test fun sessionsSearch() = snap("sessions_search") {
SessionsScreen( SessionsScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, settings = settings,
sessions = listOf( sessions = listOf(
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"), Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
@ -109,7 +111,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun sessions() = snap("sessions") { @Test fun sessions() = snap("sessions") {
SessionsScreen( SessionsScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, settings = settings,
sessions = listOf( sessions = listOf(
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"), Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
@ -125,14 +127,14 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun settings() = snap("settings") { @Test fun settings() = snap("settings") {
SettingsScreen( SettingsScreen(
state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, state = UiState(isAdmin = true, settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]", initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
) )
} }
@Test fun providers() = snap("providers") { @Test fun providers() = snap("providers") {
SettingsScreen( SettingsScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, settings = settings,
provider = ProviderUi( provider = ProviderUi(
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"), active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
@ -149,4 +151,27 @@ class PreviewScreenshots(private val dark: Boolean) {
onSave = {}, onTest = { _, _ -> }, onBack = {}, onSave = {}, onTest = { _, _ -> }, onBack = {},
) )
} }
@Test fun login() = snap("login") {
LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _, _ -> })
}
@Test fun loginRegister() = snap("login_register") {
LoginScreen(
state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), registrationMode = "invite", authError = "Einladungscode wurde bereits verwendet"),
onAuthenticate = { _, _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
)
}
@Test fun invites() = snap("invites") {
InviteSection(
state = UiState(isAdmin = true, settings = ServerSettings("https://byteclaw.jodabreit-ai.de", "t", "admin"), inviteStatus = "Neuer Code: BC-7KQM-X4TP",
invites = listOf(
dev.byteclaw.client.data.Invite("BC-7KQM-X4TP", 1, 0, null, "2026-11-01T05:00:00Z"),
dev.byteclaw.client.data.Invite("BC-H9RW-2NFD", 5, 2, "2026-11-08T05:00:00Z", "2026-11-01T04:00:00Z", listOf("anna", "ben")),
dev.byteclaw.client.data.Invite("BC-3VXE-8MZA", 1, 1, null, "2026-10-31T20:00:00Z", listOf("crypto")),
)),
onLoad = {}, onCreate = { _, _ -> }, onRevoke = {},
)
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

View file

@ -81,7 +81,12 @@ func run(cfgPath string, log *slog.Logger) error {
p, _, err := pm.ForSession(ctx, sid) p, _, err := pm.ForSession(ctx, sid)
return p, err return p, err
}} }}
srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, trusted, err := server.ParseCIDRs(cfg.TrustedProxies)
if err != nil {
log.Error("invalid trusted_proxies", "err", err)
os.Exit(1)
}
srv := &server.Server{TrustedProxies: trusted, Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), RegistrationMode: cfg.Registration(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm,
Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL}, Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL},
RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second} RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second}

View file

@ -2,7 +2,11 @@
# BYTECLAW_AUTH_TOKEN, BYTECLAW_LLM_API_KEY, BYTECLAW_LLM_BASE_URL, BYTECLAW_LLM_MODEL, # BYTECLAW_AUTH_TOKEN, BYTECLAW_LLM_API_KEY, BYTECLAW_LLM_BASE_URL, BYTECLAW_LLM_MODEL,
# BYTECLAW_LISTEN, BYTECLAW_DATA_DIR, BYTECLAW_WORKSPACE, BYTECLAW_ALLOWED_ORIGINS (comma separated) # BYTECLAW_LISTEN, BYTECLAW_DATA_DIR, BYTECLAW_WORKSPACE, BYTECLAW_ALLOWED_ORIGINS (comma separated)
listen: 127.0.0.1:8787 # bind to 0.0.0.0 only behind TLS (reverse proxy) or in a trusted LAN listen: 127.0.0.1:8787 # bind to 0.0.0.0 only behind TLS (reverse proxy) or in a trusted LAN
auth_token: "" # required, >= 16 chars. Generate: openssl rand -hex 32 auth_token: "" # optional static admin token (>= 16 chars, sees all sessions). Generate: openssl rand -hex 32
allow_registration: true # legacy switch; registration_mode wins if set
registration_mode: "" # open | invite | closed (invite: needs a code from POST /api/invites, admin only)
token_ttl_hours: 720 # login token lifetime
trusted_proxies: [] # reverse proxy IPs/CIDRs whose X-Forwarded-For is trusted (empty = loopback only)
allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it
data_dir: ./data data_dir: ./data
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it workspace: "" # default: <data_dir>/workspace; file tools cannot leave it

View file

@ -1,12 +1,26 @@
{ {
"hosting": { "hosting": {
"public": "site", "public": "site",
"ignore": ["firebase.json", "**/.*", "_headers"], "ignore": [
"firebase.json",
"**/.*",
"_headers",
"**/*.apk"
],
"headers": [ "headers": [
{"source": "**/*.apk", "headers": [ {
{"key": "Content-Type", "value": "application/vnd.android.package-archive"}, "source": "**/*.apk",
{"key": "Content-Disposition", "value": "attachment"} "headers": [
]} {
"key": "Content-Type",
"value": "application/vnd.android.package-archive"
},
{
"key": "Content-Disposition",
"value": "attachment"
}
]
}
] ]
} }
} }

5
go.mod
View file

@ -1,6 +1,6 @@
module github.com/crypto/byteclaw module github.com/crypto/byteclaw
go 1.25.0 go 1.26.0
require ( require (
github.com/coder/websocket v1.8.13 github.com/coder/websocket v1.8.13
@ -14,8 +14,9 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.34.0 // indirect golang.org/x/sys v0.49.0 // indirect
modernc.org/libc v1.66.3 // indirect modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

4
go.sum
View file

@ -12,6 +12,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
@ -21,6 +23,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View file

@ -75,10 +75,19 @@ type Tools struct {
} }
type Config struct { type Config struct {
Listen string `yaml:"listen" json:"listen"` Listen string `yaml:"listen" json:"listen"`
// AuthToken is an optional static admin token (sees all sessions). Users log in via /api/auth.
AuthToken string `yaml:"auth_token" json:"auth_token"` AuthToken string `yaml:"auth_token" json:"auth_token"`
// AllowRegistration enables self-service sign-up (default true).
AllowRegistration *bool `yaml:"allow_registration" json:"allow_registration"`
// RegistrationMode: open | invite | closed. Empty = derived from allow_registration.
RegistrationMode string `yaml:"registration_mode" json:"registration_mode"`
// TokenTTLHours is the lifetime of login tokens (default 720 = 30 days).
TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"`
// AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly. // AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly.
AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"` AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"`
// TrustedProxies (IPs/CIDRs) whose X-Forwarded-For is used for rate limiting. Empty = loopback only.
TrustedProxies []string `yaml:"trusted_proxies" json:"trusted_proxies"`
DataDir string `yaml:"data_dir" json:"data_dir"` DataDir string `yaml:"data_dir" json:"data_dir"`
Workspace string `yaml:"workspace" json:"workspace"` Workspace string `yaml:"workspace" json:"workspace"`
LLM LLM `yaml:"llm" json:"llm"` LLM LLM `yaml:"llm" json:"llm"`
@ -153,6 +162,11 @@ func applyEnv(c *Config, get func(string) string) {
set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL) set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL)
set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey) set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey)
set("BYTECLAW_LLM_MODEL", &c.LLM.Model) set("BYTECLAW_LLM_MODEL", &c.LLM.Model)
if v := get("BYTECLAW_ALLOW_REGISTRATION"); v != "" {
b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
c.AllowRegistration = &b
}
set("BYTECLAW_REGISTRATION_MODE", &c.RegistrationMode)
if v := get("BYTECLAW_ALLOWED_ORIGINS"); v != "" { if v := get("BYTECLAW_ALLOWED_ORIGINS"); v != "" {
c.AllowedOrigins = nil c.AllowedOrigins = nil
for _, o := range strings.Split(v, ",") { for _, o := range strings.Split(v, ",") {
@ -163,11 +177,31 @@ func applyEnv(c *Config, get func(string) string) {
} }
} }
// RegistrationAllowed reports allow_registration (default true).
func (c Config) RegistrationAllowed() bool { return c.AllowRegistration == nil || *c.AllowRegistration }
// Registration returns the effective registration mode (open, invite or closed).
func (c Config) Registration() string {
if m := strings.ToLower(strings.TrimSpace(c.RegistrationMode)); m != "" {
return m
}
if c.RegistrationAllowed() {
return "open"
}
return "closed"
}
// Validate enforces required fields and secure settings. // Validate enforces required fields and secure settings.
func (c Config) Validate() error { func (c Config) Validate() error {
var errs []error var errs []error
if len(c.AuthToken) < 16 { if c.AuthToken != "" && len(c.AuthToken) < 16 {
errs = append(errs, errors.New("auth_token must be set and at least 16 characters (BYTECLAW_AUTH_TOKEN)")) errs = append(errs, errors.New("auth_token must be at least 16 characters when set (BYTECLAW_AUTH_TOKEN)"))
}
if m := c.Registration(); m != "open" && m != "invite" && m != "closed" {
errs = append(errs, errors.New("registration_mode must be open, invite or closed"))
}
if c.Registration() == "invite" && c.AuthToken == "" {
errs = append(errs, errors.New("registration_mode invite needs auth_token (only the admin can create invite codes)"))
} }
if c.LLM.BaseURL == "" || c.LLM.Model == "" { if c.LLM.BaseURL == "" || c.LLM.Model == "" {
errs = append(errs, errors.New("llm.base_url and llm.model are required")) errs = append(errs, errors.New("llm.base_url and llm.model are required"))

View file

@ -36,9 +36,21 @@ func TestLoadJSON(t *testing.T) {
} }
} }
func TestValidateRequiresToken(t *testing.T) { func TestValidateShortToken(t *testing.T) {
t.Setenv("BYTECLAW_AUTH_TOKEN", "") t.Setenv("BYTECLAW_AUTH_TOKEN", "short")
if _, err := Load(""); err == nil { if _, err := Load(""); err == nil {
t.Fatal("expected error without token") t.Fatal("expected error for short token")
}
t.Setenv("BYTECLAW_AUTH_TOKEN", "")
c, err := Load("")
if err != nil {
t.Fatalf("token is optional now: %v", err)
}
if !c.RegistrationAllowed() {
t.Fatal("registration must default to true")
}
t.Setenv("BYTECLAW_ALLOW_REGISTRATION", "false")
if c, _ = Load(""); c.RegistrationAllowed() {
t.Fatal("env must disable registration")
} }
} }

View file

@ -0,0 +1,43 @@
package server
import (
"encoding/json"
"testing"
)
func TestProviderChangesAdminOnly(t *testing.T) {
ts, _ := setupFull(t, echo{})
ut := registerUser(t, ts.URL, "alice", "correct-horse-1")
uh := bearerHdr(ut)
if r := do(t, "GET", ts.URL+"/api/providers", "", uh); r.StatusCode != 200 {
t.Fatal("users may list providers", r.StatusCode)
}
if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, uh); r.StatusCode != 403 {
t.Fatal("user must not change global provider", r.StatusCode)
}
var me map[string]any
json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/me", "", uh).Body).Decode(&me)
if me["admin"] != false {
t.Fatalf("%v", me)
}
// own session: chat ok, provider change forbidden
r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, uh)
if r.StatusCode != 200 {
t.Fatal("user chat", r.StatusCode)
}
var cr struct {
SessionID string `json:"session_id"`
}
json.NewDecoder(r.Body).Decode(&cr)
if cr.SessionID != "" {
if r := do(t, "PUT", ts.URL+"/api/sessions/"+cr.SessionID+"/provider", `{"provider":"openai"}`, uh); r.StatusCode != 403 {
t.Fatal("session provider", r.StatusCode)
}
}
if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi","provider":"dahl"}`, uh); r.StatusCode != 403 {
t.Fatal("chat with provider", r.StatusCode)
}
if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, authH); r.StatusCode != 200 {
t.Fatal("admin allowed", r.StatusCode)
}
}

366
internal/server/auth.go Normal file
View file

@ -0,0 +1,366 @@
package server
import (
"context"
"crypto/subtle"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
"github.com/crypto/byteclaw/internal/store"
)
// Principal is the authenticated caller. Admin (static auth_token) sees all sessions.
type Principal struct {
UserID int64
Username string
Admin bool
token string
}
// Scope is the user filter for store queries.
func (p Principal) Scope() int64 {
if p.Admin {
return store.AllUsers
}
return p.UserID
}
// OwnerID is the user_id new sessions get.
func (p Principal) OwnerID() int64 {
if p.Admin {
return 0
}
return p.UserID
}
type ctxKey struct{}
func principal(ctx context.Context) Principal {
p, _ := ctx.Value(ctxKey{}).(Principal)
return p
}
const (
defaultTokenTTL = 30 * 24 * time.Hour
loginWindow = 15 * time.Minute
loginMaxFails = 10
)
// dummyHash keeps the timing of "unknown user" close to "wrong password".
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("byteclaw-dummy-password"), bcrypt.DefaultCost)
// limiter counts failed attempts per IP in a fixed window.
type limiter struct {
mu sync.Mutex
hits map[string][]time.Time
max int
win time.Duration
now func() time.Time
}
func newLimiter(max int, win time.Duration) *limiter {
return &limiter{hits: map[string][]time.Time{}, max: max, win: win, now: time.Now}
}
func (l *limiter) prune(key string) []time.Time {
cut := l.now().Add(-l.win)
h := l.hits[key]
i := 0
for i < len(h) && h[i].Before(cut) {
i++
}
h = h[i:]
if len(h) == 0 {
delete(l.hits, key)
} else {
l.hits[key] = h
}
return h
}
func (l *limiter) Blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.prune(key)) >= l.max
}
func (l *limiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
l.hits[key] = append(l.prune(key), l.now())
}
func (l *limiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
// clientIP returns the peer address. If the peer is a trusted proxy, the
// right-most X-Forwarded-For entry that is not itself a trusted proxy is used.
func (s *Server) clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
h = r.RemoteAddr
}
if !s.isTrustedProxy(h) {
return h
}
var parts []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
parts = append(parts, p)
}
}
}
for i := len(parts) - 1; i >= 0; i-- {
if net.ParseIP(parts[i]) == nil {
break
}
if !s.isTrustedProxy(parts[i]) {
return parts[i]
}
}
return h
}
func (s *Server) isTrustedProxy(ip string) bool {
p := net.ParseIP(ip)
if p == nil {
return false
}
if len(s.TrustedProxies) == 0 {
return p.IsLoopback()
}
for _, c := range s.TrustedProxies {
if c.Contains(p) {
return true
}
}
return false
}
// ParseCIDRs turns IPs or CIDRs into networks (bare IPs become /32 or /128).
func ParseCIDRs(in []string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, v := range in {
if !strings.Contains(v, "/") {
if ip := net.ParseIP(v); ip != nil && ip.To4() != nil {
v += "/32"
} else {
v += "/128"
}
}
_, n, err := net.ParseCIDR(v)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, nil
}
func (s *Server) initAuth() {
s.authOnce.Do(func() {
if s.limiter == nil {
s.limiter = newLimiter(loginMaxFails, loginWindow)
}
if s.TokenTTL <= 0 {
s.TokenTTL = defaultTokenTTL
}
})
}
func bearer(r *http.Request) string {
if t, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
return strings.TrimSpace(t)
}
// Browsers cannot set headers on WebSocket upgrades: allow ?token= there only.
if r.URL.Path == "/api/ws" {
return r.URL.Query().Get("token")
}
return ""
}
// auth accepts the static admin token (constant-time) or a user token.
func (s *Server) auth(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := bearer(r)
var p Principal
switch {
case got == "":
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
case s.Token != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) == 1:
p = Principal{Admin: true, Username: "admin"}
default:
u, err := s.Store.UserByToken(r.Context(), got)
if err != nil {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
p = Principal{UserID: u.ID, Username: u.Username}
}
p.token = got
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p)))
})
}
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
InviteCode string `json:"invite_code,omitempty"`
}
func validUsername(u string) bool {
if n := utf8.RuneCountInString(u); n < 3 || n > 32 {
return false
}
for _, c := range u {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') {
return false
}
}
return true
}
func validPassword(p string) bool {
// bcrypt only uses the first 72 bytes; reject longer input instead of silently truncating.
return utf8.RuneCountInString(p) >= 8 && len(p) <= 72
}
func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, code int) {
tok, exp, err := s.Store.IssueToken(r.Context(), u.ID, s.TokenTTL)
if err != nil {
s.storeErr(w, err)
return
}
writeJSON(w, code, map[string]any{"token": tok, "expires_at": exp, "user": map[string]any{"id": u.ID, "username": u.Username}})
}
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
s.initAuth()
mode := s.regMode()
if mode == "closed" {
writeErr(w, http.StatusForbidden, "registration disabled")
return
}
ip := s.clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
}
var c credentials
if !decode(w, r, &c) {
return
}
c.Username = strings.TrimSpace(c.Username)
if !validUsername(c.Username) {
writeErr(w, http.StatusBadRequest, "username must be 3-32 characters (letters, digits, . _ -)")
return
}
if !validPassword(c.Password) {
writeErr(w, http.StatusBadRequest, "password must be 8-72 characters")
return
}
code := ""
if mode == "invite" {
if code = store.NormalizeInviteCode(c.InviteCode); code == "" {
writeErr(w, http.StatusBadRequest, "invite code required")
return
}
}
s.limiter.Fail(ip) // every registration counts against the IP budget
hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost)
if err != nil {
s.storeErr(w, err)
return
}
u, err := s.Store.CreateUserWithInvite(r.Context(), c.Username, string(hash), code)
if errors.Is(err, store.ErrInviteInvalid) || errors.Is(err, store.ErrInviteUsed) || errors.Is(err, store.ErrInviteExpired) {
writeErr(w, http.StatusForbidden, err.Error())
return
}
if errors.Is(err, store.ErrUserExists) {
writeErr(w, http.StatusConflict, "username already taken")
return
}
if err != nil {
s.storeErr(w, err)
return
}
s.issue(w, r, u, http.StatusCreated)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
s.initAuth()
ip := s.clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
}
var c credentials
if !decode(w, r, &c) {
return
}
u, err := s.Store.UserByName(r.Context(), strings.TrimSpace(c.Username))
hash := dummyHash
if err == nil {
hash = []byte(u.PasswordHash())
} else if !errors.Is(err, store.ErrNotFound) {
s.storeErr(w, err)
return
}
if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil {
s.limiter.Fail(ip)
writeErr(w, http.StatusUnauthorized, "invalid username or password")
return
}
s.limiter.Reset(ip)
s.issue(w, r, u, http.StatusOK)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
p := principal(r.Context())
if !p.Admin {
if err := s.Store.RevokeToken(r.Context(), p.token); err != nil {
s.storeErr(w, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
p := principal(r.Context())
writeJSON(w, 200, map[string]any{"id": p.UserID, "username": p.Username, "admin": p.Admin})
}
// owns writes 404 and returns false unless the caller may access session id.
func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool {
if err := s.Store.SessionOwnedBy(r.Context(), principal(r.Context()).Scope(), id); err != nil {
s.storeErr(w, err)
return false
}
return true
}
// regMode is the effective registration mode.
func (s *Server) regMode() string {
switch s.RegistrationMode {
case "open", "invite", "closed":
return s.RegistrationMode
}
if s.AllowRegistration {
return "open"
}
return "closed"
}

View file

@ -0,0 +1,200 @@
package server
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/crypto/byteclaw/internal/agent"
"github.com/crypto/byteclaw/internal/store"
"github.com/crypto/byteclaw/internal/tools"
)
func authSetup(t *testing.T, allowReg bool) (*httptest.Server, *store.Store, *Server) {
st, err := store.Open(filepath.Join(t.TempDir(), "s.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
s := &Server{Store: st, Token: tok, AllowRegistration: allowReg, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second,
Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}}
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return ts, st, s
}
type authResp struct {
Token string `json:"token"`
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
} `json:"user"`
}
func creds(u, p string) string {
b, _ := json.Marshal(map[string]string{"username": u, "password": p})
return string(b)
}
func registerUser(t *testing.T, base, u, p string) string {
t.Helper()
r := do(t, "POST", base+"/api/auth/register", creds(u, p), nil)
if r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Fatalf("register %s: %d %s", u, r.StatusCode, b)
}
var a authResp
json.NewDecoder(r.Body).Decode(&a)
if a.Token == "" || a.User.Username != u {
t.Fatalf("bad register response %+v", a)
}
return a.Token
}
func bearerHdr(t string) map[string]string { return map[string]string{"Authorization": "Bearer " + t} }
func TestRegisterLoginMeLogout(t *testing.T) {
ts, _, _ := authSetup(t, true)
t1 := registerUser(t, ts.URL, "alice", "supersecret")
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("ALICE", "supersecret"), nil); r.StatusCode != 409 {
t.Fatalf("duplicate (case-insensitive) must be 409, got %d", r.StatusCode)
}
r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1))
var me map[string]any
json.NewDecoder(r.Body).Decode(&me)
if r.StatusCode != 200 || me["username"] != "alice" {
t.Fatalf("me: %d %v", r.StatusCode, me)
}
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrongpass"), nil); r.StatusCode != 401 {
t.Fatalf("wrong password: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("nobody", "whatever1"), nil); r.StatusCode != 401 {
t.Fatalf("unknown user: %d", r.StatusCode)
}
r = do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil)
var a authResp
json.NewDecoder(r.Body).Decode(&a)
if r.StatusCode != 200 || a.Token == "" || a.Token == t1 {
t.Fatalf("login: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/logout", "", bearerHdr(a.Token)); r.StatusCode != 204 {
t.Fatalf("logout: %d", r.StatusCode)
}
if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(a.Token)); r.StatusCode != 401 {
t.Fatalf("revoked token must be 401, got %d", r.StatusCode)
}
if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)); r.StatusCode != 200 {
t.Fatalf("other token must stay valid, got %d", r.StatusCode)
}
}
func TestRegisterValidation(t *testing.T) {
ts, _, _ := authSetup(t, true)
for _, c := range []struct{ u, p string }{{"ab", "longenough"}, {strings.Repeat("a", 33), "longenough"}, {"bad name", "longenough"}, {"bob", "short"}} {
if r := do(t, "POST", ts.URL+"/api/auth/register", creds(c.u, c.p), nil); r.StatusCode != 400 {
t.Fatalf("%q/%q: want 400 got %d", c.u, c.p, r.StatusCode)
}
}
}
func TestRegistrationDisabled(t *testing.T) {
ts, _, _ := authSetup(t, false)
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "supersecret"), nil); r.StatusCode != 403 {
t.Fatalf("want 403 got %d", r.StatusCode)
}
}
func TestAuthRequired(t *testing.T) {
ts, _, _ := authSetup(t, true)
for _, p := range []string{"/api/sessions", "/api/info", "/api/auth/me", "/api/providers", "/api/ws"} {
if r := do(t, "GET", ts.URL+p, "", nil); r.StatusCode != 401 {
t.Fatalf("%s without token: %d", p, r.StatusCode)
}
if r := do(t, "GET", ts.URL+p, "", bearerHdr("bc_invalid")); r.StatusCode != 401 {
t.Fatalf("%s bad token: %d", p, r.StatusCode)
}
}
if r := do(t, "GET", ts.URL+"/api/sessions?token="+tok, "", nil); r.StatusCode != 401 {
t.Fatalf("query token must only work for ws, got %d", r.StatusCode)
}
// static admin token still works
if r := do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tok)); r.StatusCode != 200 {
t.Fatalf("static token: %d", r.StatusCode)
}
}
func TestPerUserIsolation(t *testing.T) {
ts, st, _ := authSetup(t, true)
legacy, _ := st.CreateSession(t.Context(), "legacy")
ta := registerUser(t, ts.URL, "alice", "supersecret")
tb := registerUser(t, ts.URL, "bob_2", "supersecret")
r := do(t, "POST", ts.URL+"/api/sessions", `{"title":"bob's"}`, bearerHdr(tb))
var bs store.Session
json.NewDecoder(r.Body).Decode(&bs)
list := func(tk string) []store.Session {
var l []store.Session
json.NewDecoder(do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tk)).Body).Decode(&l)
return l
}
if l := list(ta); len(l) != 1 || l[0].ID != legacy.ID {
t.Fatalf("first user must adopt legacy session, got %+v", l)
}
if l := list(tb); len(l) != 1 || l[0].ID != bs.ID {
t.Fatalf("bob sees %+v", l)
}
for _, m := range []struct{ method, path string }{{"GET", ""}, {"GET", "/messages"}, {"DELETE", ""}, {"POST", "/cancel"}, {"GET", "/summary"}} {
if r := do(t, m.method, ts.URL+"/api/sessions/"+bs.ID+m.path, "", bearerHdr(ta)); r.StatusCode != 404 {
t.Fatalf("alice %s %s on bob's session: %d", m.method, m.path, r.StatusCode)
}
}
if r := do(t, "PATCH", ts.URL+"/api/sessions/"+bs.ID, `{"title":"x"}`, bearerHdr(ta)); r.StatusCode != 404 {
t.Fatalf("rename foreign: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/chat", `{"session_id":"`+bs.ID+`","text":"hi"}`, bearerHdr(ta)); r.StatusCode != 404 {
t.Fatalf("chat into foreign session: %d", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, bearerHdr(ta)); r.StatusCode != 200 {
t.Fatalf("own chat: %d", r.StatusCode)
}
if l := list(tb); len(l) != 1 {
t.Fatalf("alice's new chat leaked to bob: %+v", l)
}
if l := list(tok); len(l) != 3 {
t.Fatalf("admin must see all sessions, got %d", len(l))
}
}
func TestLoginRateLimit(t *testing.T) {
ts, _, s := authSetup(t, true)
registerUser(t, ts.URL, "alice", "supersecret")
s.limiter.Reset("127.0.0.1")
for i := 0; i < loginMaxFails; i++ {
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrong-pass"), nil); r.StatusCode != 401 {
t.Fatalf("attempt %d: %d", i, r.StatusCode)
}
}
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 429 {
t.Fatalf("want 429 after %d failures, got %d", loginMaxFails, r.StatusCode)
}
// window expiry unblocks
s.limiter.now = func() time.Time { return time.Now().Add(loginWindow + time.Second) }
if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 200 {
t.Fatalf("after window: %d", r.StatusCode)
}
}
func TestWSQueryToken(t *testing.T) {
ts, _, _ := authSetup(t, true)
tk := registerUser(t, ts.URL, "alice", "supersecret")
r := do(t, "GET", ts.URL+"/api/ws?token="+tk, "", map[string]string{"Connection": "Upgrade", "Upgrade": "websocket", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ=="})
if r.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("ws with query token: %d", r.StatusCode)
}
}

View file

@ -0,0 +1,26 @@
package server
import (
"net/http/httptest"
"testing"
)
func TestClientIPTrustedProxy(t *testing.T) {
s := &Server{}
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "127.0.0.1:1234"
r.Header.Set("X-Forwarded-For", "6.6.6.6, 1.2.3.4")
if got := s.clientIP(r); got != "1.2.3.4" {
t.Fatalf("loopback proxy: got %s", got)
}
r.RemoteAddr = "9.9.9.9:1"
if got := s.clientIP(r); got != "9.9.9.9" {
t.Fatalf("untrusted peer: got %s", got)
}
n, _ := ParseCIDRs([]string{"10.0.1.0/24"})
s.TrustedProxies = n
r.RemoteAddr = "10.0.1.6:1"
if got := s.clientIP(r); got != "1.2.3.4" {
t.Fatalf("cidr proxy: got %s", got)
}
}

View file

@ -48,6 +48,9 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) {
} }
func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) { func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if !s.needProviders(w) { if !s.needProviders(w) {
return return
} }
@ -82,6 +85,9 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
// PUT /api/sessions/{id}/provider: per-session override; empty provider clears it. // PUT /api/sessions/{id}/provider: per-session override; empty provider clears it.
func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if !s.needProviders(w) { if !s.needProviders(w) {
return return
} }
@ -90,8 +96,7 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
return return
} }
id := r.PathValue("id") id := r.PathValue("id")
if _, err := s.Store.GetSession(r.Context(), id); err != nil { if !s.owns(w, r, id) {
s.storeErr(w, err)
return return
} }
var a providers.Active var a providers.Active
@ -110,13 +115,15 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
writeJSON(w, 200, map[string]bool{"cancelled": s.Agent.Cancel(r.PathValue("id"))}) writeJSON(w, 200, map[string]bool{"cancelled": s.Agent.Cancel(r.PathValue("id"))})
} }
func (s *Server) summarize(w http.ResponseWriter, r *http.Request) { func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
if _, err := s.Store.GetSession(r.Context(), id); err != nil { if !s.owns(w, r, id) {
s.storeErr(w, err)
return return
} }
sm, err := s.Agent.SummarizeNow(r.Context(), id) sm, err := s.Agent.SummarizeNow(r.Context(), id)
@ -133,6 +140,9 @@ func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) { func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
sm, err := s.Store.LatestSummary(r.Context(), r.PathValue("id")) sm, err := s.Store.LatestSummary(r.Context(), r.PathValue("id"))
if err != nil { if err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
@ -144,3 +154,15 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, 200, sm) writeJSON(w, 200, sm)
} }
// errAdminOnly is returned when a non-admin tries to change provider settings.
var errAdminOnly = errors.New("admin only: provider settings can only be changed by the server admin")
// requireAdmin writes 403 unless the caller is the admin (static auth_token).
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if principal(r.Context()).Admin {
return true
}
writeErr(w, http.StatusForbidden, errAdminOnly.Error())
return false
}

View file

@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) {
pr, _, err := pm.ForSession(ctx, sid) pr, _, err := pm.ForSession(ctx, sid)
return pr, err return pr, err
} }
s := &Server{Store: st, Token: tok, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} s := &Server{Store: st, Token: tok, AllowRegistration: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm}
ts := httptest.NewServer(s.Handler()) ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
return ts, st return ts, st

View file

@ -0,0 +1,54 @@
package server
import (
"net/http"
"time"
)
type inviteReq struct {
MaxUses int `json:"max_uses"`
ExpiresInHours int `json:"expires_in_hours"`
}
func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
var b inviteReq
if r.ContentLength != 0 && !decode(w, r, &b) {
return
}
if b.MaxUses < 0 || b.MaxUses > 1000 || b.ExpiresInHours < 0 || b.ExpiresInHours > 24*365 {
writeErr(w, http.StatusBadRequest, "max_uses must be 1-1000, expires_in_hours 0-8760")
return
}
inv, err := s.Store.CreateInvite(r.Context(), b.MaxUses, time.Duration(b.ExpiresInHours)*time.Hour)
if err != nil {
s.storeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, inv)
}
func (s *Server) listInvites(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
list, err := s.Store.ListInvites(r.Context())
if err != nil {
s.storeErr(w, err)
return
}
writeJSON(w, 200, list)
}
func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if err := s.Store.DeleteInvite(r.Context(), r.PathValue("code")); err != nil {
s.storeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,145 @@
package server
import (
"context"
"encoding/json"
"io"
"regexp"
"strings"
"sync"
"testing"
"time"
)
func regBody(u, p, code string) string {
b, _ := json.Marshal(map[string]string{"username": u, "password": p, "invite_code": code})
return string(b)
}
func TestInviteRegistration(t *testing.T) {
ts, st, s := authSetup(t, true)
s.RegistrationMode = "invite"
adm := bearerHdr(tok)
var cfg map[string]string
json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/config", "", nil).Body).Decode(&cfg)
if cfg["registration_mode"] != "invite" {
t.Fatalf("%v", cfg)
}
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "password-1"), nil); r.StatusCode != 400 {
t.Fatal("no code must be 400", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("alice", "password-1", "BC-NOPE-NOPE"), nil); r.StatusCode != 403 {
t.Fatal("unknown code must be 403", r.StatusCode)
}
// only admin may manage codes
ut := ""
{
s.RegistrationMode = "open"
ut = registerUser(t, ts.URL, "bob", "password-1")
s.RegistrationMode = "invite"
}
for _, m := range []string{"GET", "POST"} {
if r := do(t, m, ts.URL+"/api/invites", "", bearerHdr(ut)); r.StatusCode != 403 {
t.Fatal(m, "invites must be admin only", r.StatusCode)
}
}
r := do(t, "POST", ts.URL+"/api/invites", "", adm)
if r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Fatal(r.StatusCode, string(b))
}
var inv struct {
Code string `json:"code"`
MaxUses int `json:"max_uses"`
}
json.NewDecoder(r.Body).Decode(&inv)
if !regexp.MustCompile(`^BC-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`).MatchString(inv.Code) || inv.MaxUses != 1 {
t.Fatalf("%+v", inv)
}
// lower-case input is accepted
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("alice", "password-1", " "+strings.ToLower(inv.Code)), nil); r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Fatal("register with code", r.StatusCode, string(b))
}
r = do(t, "POST", ts.URL+"/api/auth/register", regBody("carol", "password-1", inv.Code), nil)
b, _ := io.ReadAll(r.Body)
if r.StatusCode != 403 || !strings.Contains(string(b), "already used") {
t.Fatal("reuse", r.StatusCode, string(b))
}
// list shows usage
var list []struct {
Code string `json:"code"`
Uses int `json:"uses"`
UsedBy []string `json:"used_by"`
}
json.NewDecoder(do(t, "GET", ts.URL+"/api/invites", "", adm).Body).Decode(&list)
if len(list) != 1 || list[0].Uses != 1 || len(list[0].UsedBy) != 1 || list[0].UsedBy[0] != "alice" {
t.Fatalf("%+v", list)
}
// multi-use + revoke
r = do(t, "POST", ts.URL+"/api/invites", `{"max_uses":5,"expires_in_hours":24}`, adm)
json.NewDecoder(r.Body).Decode(&inv)
if inv.MaxUses != 5 {
t.Fatalf("%+v", inv)
}
if r := do(t, "DELETE", ts.URL+"/api/invites/"+inv.Code, "", adm); r.StatusCode != 204 {
t.Fatal("revoke", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("dave", "password-1", inv.Code), nil); r.StatusCode != 403 {
t.Fatal("revoked code", r.StatusCode)
}
if r := do(t, "DELETE", ts.URL+"/api/invites/"+inv.Code, "", adm); r.StatusCode != 404 {
t.Fatal("revoke twice", r.StatusCode)
}
// expired
ex, _ := st.CreateInvite(context.Background(), 1, time.Millisecond)
time.Sleep(5 * time.Millisecond)
r = do(t, "POST", ts.URL+"/api/auth/register", regBody("erin", "password-1", ex.Code), nil)
b, _ = io.ReadAll(r.Body)
if r.StatusCode != 403 || !strings.Contains(string(b), "expired") {
t.Fatal("expired", r.StatusCode, string(b))
}
// closed mode
s.RegistrationMode = "closed"
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("frank", "password-1", ""), nil); r.StatusCode != 403 {
t.Fatal("closed", r.StatusCode)
}
}
func TestInviteConsumedAtomically(t *testing.T) {
_, st, _ := authSetup(t, true)
ctx := context.Background()
inv, err := st.CreateInvite(ctx, 3, 0)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var mu sync.Mutex
ok := 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if _, err := st.CreateUserWithInvite(ctx, "u"+string(rune('a'+i))+"xx", "h", inv.Code); err == nil {
mu.Lock()
ok++
mu.Unlock()
}
}(i)
}
wg.Wait()
if ok != 3 {
t.Fatalf("expected exactly 3 registrations, got %d", ok)
}
// failed user insert must not burn a use
inv2, _ := st.CreateInvite(ctx, 1, 0)
st.CreateUser(ctx, "dupe", "h")
if _, err := st.CreateUserWithInvite(ctx, "dupe", "h", inv2.Code); err == nil {
t.Fatal("duplicate username must fail")
}
if _, err := st.CreateUserWithInvite(ctx, "fresh", "h", inv2.Code); err != nil {
t.Fatal("code must still be usable after failed insert:", err)
}
}

View file

@ -3,10 +3,10 @@ package server
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -35,6 +35,17 @@ type Server struct {
Info Info Info Info
// Providers enables runtime provider switching (optional). // Providers enables runtime provider switching (optional).
Providers *providers.Manager Providers *providers.Manager
// AllowRegistration enables POST /api/auth/register.
AllowRegistration bool
// RegistrationMode overrides AllowRegistration: "open", "invite" or "closed".
RegistrationMode string
// TokenTTL is the lifetime of user tokens (default 30 days).
TokenTTL time.Duration
// TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only).
TrustedProxies []*net.IPNet
authOnce sync.Once
limiter *limiter
} }
type Info struct { type Info struct {
@ -47,8 +58,19 @@ type Info struct {
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
s.initAuth()
mux.HandleFunc("POST /api/auth/register", s.register)
mux.HandleFunc("POST /api/auth/login", s.login)
mux.HandleFunc("GET /api/auth/config", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]string{"registration_mode": s.regMode()})
})
api := http.NewServeMux() api := http.NewServeMux()
api.HandleFunc("POST /api/auth/logout", s.logout)
api.HandleFunc("GET /api/auth/me", s.me)
api.HandleFunc("GET /api/info", s.info) api.HandleFunc("GET /api/info", s.info)
api.HandleFunc("GET /api/invites", s.listInvites)
api.HandleFunc("POST /api/invites", s.createInvite)
api.HandleFunc("DELETE /api/invites/{code}", s.deleteInvite)
api.HandleFunc("GET /api/providers", s.listProviders) api.HandleFunc("GET /api/providers", s.listProviders)
api.HandleFunc("PUT /api/providers/active", s.setActiveProvider) api.HandleFunc("PUT /api/providers/active", s.setActiveProvider)
api.HandleFunc("GET /api/providers/{name}/models", s.listModels) api.HandleFunc("GET /api/providers/{name}/models", s.listModels)
@ -64,8 +86,10 @@ func (s *Server) Handler() http.Handler {
api.HandleFunc("GET /api/sessions/{id}/messages", s.listMessages) api.HandleFunc("GET /api/sessions/{id}/messages", s.listMessages)
api.HandleFunc("POST /api/chat", s.chat) api.HandleFunc("POST /api/chat", s.chat)
api.HandleFunc("GET /api/ws", s.ws) api.HandleFunc("GET /api/ws", s.ws)
mux.Handle("/api/", s.originCheck(s.auth(api))) mux.Handle("/api/", s.auth(api))
return securityHeaders(mux) h := http.Handler(mux)
h = s.originCheck(h)
return securityHeaders(h)
} }
func securityHeaders(h http.Handler) http.Handler { func securityHeaders(h http.Handler) http.Handler {
@ -76,18 +100,6 @@ func securityHeaders(h http.Handler) http.Handler {
}) })
} }
// auth requires "Authorization: Bearer <token>" (constant-time compare).
func (s *Server) auth(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) != 1 {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
h.ServeHTTP(w, r)
})
}
// OriginAllowed: requests without Origin (native apps, curl) pass; browser // OriginAllowed: requests without Origin (native apps, curl) pass; browser
// requests must come from the same host or an explicitly configured origin. // requests must come from the same host or an explicitly configured origin.
func (s *Server) OriginAllowed(r *http.Request) bool { func (s *Server) OriginAllowed(r *http.Request) bool {
@ -158,7 +170,7 @@ func (s *Server) storeErr(w http.ResponseWriter, err error) {
} }
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) { func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
list, err := s.Store.ListSessions(r.Context(), 200) list, err := s.Store.ListSessionsFor(r.Context(), principal(r.Context()).Scope(), 200)
if err != nil { if err != nil {
s.storeErr(w, err) s.storeErr(w, err)
return return
@ -173,7 +185,7 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
if r.ContentLength != 0 && !decode(w, r, &b) { if r.ContentLength != 0 && !decode(w, r, &b) {
return return
} }
ss, err := s.Store.CreateSession(r.Context(), b.Title) ss, err := s.Store.CreateSessionFor(r.Context(), principal(r.Context()).OwnerID(), b.Title)
if err != nil { if err != nil {
s.storeErr(w, err) s.storeErr(w, err)
return return
@ -182,6 +194,9 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) getSession(w http.ResponseWriter, r *http.Request) { func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
ss, err := s.Store.GetSession(r.Context(), r.PathValue("id")) ss, err := s.Store.GetSession(r.Context(), r.PathValue("id"))
if err != nil { if err != nil {
s.storeErr(w, err) s.storeErr(w, err)
@ -197,6 +212,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
if !decode(w, r, &b) { if !decode(w, r, &b) {
return return
} }
if !s.owns(w, r, r.PathValue("id")) {
return
}
if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil { if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil {
s.storeErr(w, err) s.storeErr(w, err)
return return
@ -205,6 +223,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
if !s.owns(w, r, r.PathValue("id")) {
return
}
if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil { if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil {
s.storeErr(w, err) s.storeErr(w, err)
return return
@ -214,8 +235,7 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
if _, err := s.Store.GetSession(r.Context(), id); err != nil { if !s.owns(w, r, id) {
s.storeErr(w, err)
return return
} }
msgs, err := s.Store.Messages(r.Context(), id, 0) msgs, err := s.Store.Messages(r.Context(), id, 0)
@ -241,6 +261,9 @@ type chatReq struct {
func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) { func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) {
var a providers.Active var a providers.Active
if req.SessionID == "" && req.Provider != "" && s.Providers != nil { if req.SessionID == "" && req.Provider != "" && s.Providers != nil {
if !principal(ctx).Admin {
return "", errAdminOnly
}
var err error var err error
if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil { if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil {
return "", err return "", err
@ -256,14 +279,13 @@ func (s *Server) startSession(ctx context.Context, req chatReq) (string, error)
// ensureSession returns an existing session id or creates one titled after the message. // ensureSession returns an existing session id or creates one titled after the message.
func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) { func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) {
if id != "" { if id != "" {
_, err := s.Store.GetSession(ctx, id) return id, s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), id)
return id, err
} }
title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " ")) title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
if utf8.RuneCountInString(title) > 48 { if utf8.RuneCountInString(title) > 48 {
title = string([]rune(title)[:48]) + "…" title = string([]rune(title)[:48]) + "…"
} }
ss, err := s.Store.CreateSession(ctx, title) ss, err := s.Store.CreateSessionFor(ctx, principal(ctx).OwnerID(), title)
return ss.ID, err return ss.ID, err
} }
@ -277,6 +299,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
return return
} }
id, err := s.startSession(r.Context(), req) id, err := s.startSession(r.Context(), req)
if errors.Is(err, errAdminOnly) {
writeErr(w, http.StatusForbidden, err.Error())
return
}
if err != nil { if err != nil {
if req.SessionID == "" && req.Provider != "" { if req.SessionID == "" && req.Provider != "" {
providerErr(w, err) providerErr(w, err)
@ -337,7 +363,7 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
send(agent.Event{Type: "pong"}) send(agent.Event{Type: "pong"})
continue continue
case "cancel": case "cancel":
if req.SessionID == "" || !s.Agent.Cancel(req.SessionID) { if req.SessionID == "" || s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), req.SessionID) != nil || !s.Agent.Cancel(req.SessionID) {
send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID}) send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID})
} }
continue continue

163
internal/store/invites.go Normal file
View file

@ -0,0 +1,163 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"math/big"
"strings"
"time"
)
var (
ErrInviteInvalid = errors.New("invalid invite code")
ErrInviteUsed = errors.New("invite code already used")
ErrInviteExpired = errors.New("invite code expired")
)
// Invite is a registration code. ExpiresAt is zero when it never expires.
type Invite struct {
Code string `json:"code"`
MaxUses int `json:"max_uses"`
Uses int `json:"uses"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UsedBy []string `json:"used_by"`
}
// No 0/O/1/I/L to keep codes easy to read and type.
const inviteAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
func newInviteCode() (string, error) {
var b strings.Builder
b.WriteString("BC-")
for i := 0; i < 8; i++ {
if i == 4 {
b.WriteByte('-')
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(inviteAlphabet))))
if err != nil {
return "", err
}
b.WriteByte(inviteAlphabet[n.Int64()])
}
return b.String(), nil
}
// NormalizeInviteCode upper-cases and trims user input ("bc-abcd-efgh " -> "BC-ABCD-EFGH").
func NormalizeInviteCode(c string) string { return strings.ToUpper(strings.TrimSpace(c)) }
// CreateInvite stores a new random code. maxUses < 1 means 1; ttl <= 0 means no expiry.
func (s *Store) CreateInvite(ctx context.Context, maxUses int, ttl time.Duration) (Invite, error) {
if maxUses < 1 {
maxUses = 1
}
now := time.Now().UTC()
inv := Invite{MaxUses: maxUses, CreatedAt: now, UsedBy: []string{}}
var exp int64
if ttl > 0 {
t := now.Add(ttl)
inv.ExpiresAt = &t
exp = t.UnixMilli()
}
for attempt := 0; attempt < 5; attempt++ {
code, err := newInviteCode()
if err != nil {
return Invite{}, err
}
_, err = s.db.ExecContext(ctx, `INSERT INTO invites(code,max_uses,uses,expires_at,created_at) VALUES(?,?,0,?,?)`, code, maxUses, exp, now.UnixMilli())
if err == nil {
inv.Code = code
return inv, nil
}
if !strings.Contains(err.Error(), "UNIQUE") {
return Invite{}, err
}
}
return Invite{}, errors.New("could not generate unique invite code")
}
// ListInvites returns all codes, newest first, with the usernames that used them.
func (s *Store) ListInvites(ctx context.Context) ([]Invite, error) {
rows, err := s.db.QueryContext(ctx, `SELECT code,max_uses,uses,expires_at,created_at FROM invites ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
var out []Invite
idx := map[string]int{}
for rows.Next() {
var inv Invite
var exp, c int64
if err := rows.Scan(&inv.Code, &inv.MaxUses, &inv.Uses, &exp, &c); err != nil {
rows.Close()
return nil, err
}
inv.CreatedAt = time.UnixMilli(c).UTC()
if exp > 0 {
t := time.UnixMilli(exp).UTC()
inv.ExpiresAt = &t
}
inv.UsedBy = []string{}
idx[inv.Code] = len(out)
out = append(out, inv)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
ur, err := s.db.QueryContext(ctx, `SELECT code,username FROM invite_uses ORDER BY used_at`)
if err != nil {
return nil, err
}
defer ur.Close()
for ur.Next() {
var code, name string
if err := ur.Scan(&code, &name); err != nil {
return nil, err
}
if i, ok := idx[code]; ok {
out[i].UsedBy = append(out[i].UsedBy, name)
}
}
if out == nil {
out = []Invite{}
}
return out, ur.Err()
}
// DeleteInvite revokes a code. Users who already registered with it stay.
func (s *Store) DeleteInvite(ctx context.Context, code string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM invites WHERE code=?`, NormalizeInviteCode(code))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func consumeInvite(ctx context.Context, tx *sql.Tx, code string, now time.Time) error {
var maxUses, uses int
var exp int64
err := tx.QueryRowContext(ctx, `SELECT max_uses,uses,expires_at FROM invites WHERE code=?`, code).Scan(&maxUses, &uses, &exp)
if errors.Is(err, sql.ErrNoRows) {
return ErrInviteInvalid
}
if err != nil {
return err
}
if exp > 0 && now.UnixMilli() >= exp {
return ErrInviteExpired
}
// Conditional update: never exceeds max_uses even under concurrency.
res, err := tx.ExecContext(ctx, `UPDATE invites SET uses=uses+1 WHERE code=? AND uses<max_uses`, code)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInviteUsed
}
return nil
}

View file

@ -54,6 +54,23 @@ CREATE TABLE IF NOT EXISTS summaries(
upto_message_id INTEGER NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL); upto_message_id INTEGER NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, id); CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, id);
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS auth_tokens(
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
CREATE TABLE IF NOT EXISTS invites(
code TEXT PRIMARY KEY, max_uses INTEGER NOT NULL DEFAULT 1, uses INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS invite_uses(
code TEXT NOT NULL REFERENCES invites(code) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
username TEXT NOT NULL, used_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_invite_uses_code ON invite_uses(code);
` `
// migrations add columns to databases created by older versions. // migrations add columns to databases created by older versions.
@ -61,6 +78,7 @@ var migrations = []struct{ table, column, ddl string }{
{"sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT ''"}, {"sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT ''"},
{"sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''"}, {"sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''"},
{"messages", "cancelled", "ALTER TABLE messages ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0"}, {"messages", "cancelled", "ALTER TABLE messages ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0"},
{"sessions", "user_id", "ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0"},
} }
func migrate(db *sql.DB) error { func migrate(db *sql.DB) error {
@ -103,10 +121,15 @@ func newID() string {
} }
func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) { func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) {
return s.CreateSessionFor(ctx, 0, title)
}
// CreateSessionFor creates a session owned by userID (0 = legacy/admin).
func (s *Store) CreateSessionFor(ctx context.Context, userID int64, title string) (Session, error) {
now := time.Now().UTC() now := time.Now().UTC()
ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now} ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now}
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,created_at,updated_at) VALUES(?,?,?,?)`, _, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,user_id,created_at,updated_at) VALUES(?,?,?,?,?)`,
ss.ID, ss.Title, now.UnixMilli(), now.UnixMilli()) ss.ID, ss.Title, userID, now.UnixMilli(), now.UnixMilli())
return ss, err return ss, err
} }
@ -122,7 +145,16 @@ func (s *Store) GetSession(ctx context.Context, id string) (Session, error) {
} }
func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) { func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?`, limit) return s.ListSessionsFor(ctx, AllUsers, limit)
}
// AllUsers disables per-user scoping (admin / static token).
const AllUsers int64 = -1
// ListSessionsFor lists the sessions of userID (AllUsers = every session).
func (s *Store) ListSessionsFor(ctx context.Context, userID int64, limit int) ([]Session, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions
WHERE (?<0 OR user_id=?) ORDER BY updated_at DESC LIMIT ?`, userID, userID, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }

128
internal/store/users.go Normal file
View file

@ -0,0 +1,128 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"time"
)
var ErrUserExists = errors.New("username already taken")
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
hash string
}
func (u User) PasswordHash() string { return u.hash }
// CreateUser inserts a user. The first user ever created adopts all sessions
// that existed before accounts were introduced (user_id 0).
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
return s.CreateUserWithInvite(ctx, username, passwordHash, "")
}
// CreateUserWithInvite creates a user and, if code is non-empty, consumes one
// use of that invite code in the same transaction (all or nothing).
func (s *Store) CreateUserWithInvite(ctx context.Context, username, passwordHash, code string) (User, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return User{}, err
}
defer tx.Rollback()
if code != "" {
if err := consumeInvite(ctx, tx, code, now); err != nil {
return User{}, err
}
}
var n int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
return User{}, err
}
res, err := tx.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`, username, passwordHash, now.UnixMilli())
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return User{}, ErrUserExists
}
return User{}, err
}
id, _ := res.LastInsertId()
if code != "" {
if _, err := tx.ExecContext(ctx, `INSERT INTO invite_uses(code,user_id,username,used_at) VALUES(?,?,?,?)`, code, id, username, now.UnixMilli()); err != nil {
return User{}, err
}
}
if n == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
return User{}, err
}
}
return User{ID: id, Username: username, CreatedAt: now, hash: passwordHash}, tx.Commit()
}
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
var u User
var c int64
err := s.db.QueryRowContext(ctx, `SELECT id,username,password_hash,created_at FROM users WHERE username=?`, username).Scan(&u.ID, &u.Username, &u.hash, &c)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
u.CreatedAt = time.UnixMilli(c).UTC()
return u, err
}
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
}
// IssueToken creates a random opaque token; only its SHA-256 is stored.
func (s *Store) IssueToken(ctx context.Context, userID int64, ttl time.Duration) (string, time.Time, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", time.Time{}, err
}
tok := "bc_" + base64.RawURLEncoding.EncodeToString(b)
now := time.Now().UTC()
exp := now.Add(ttl)
_, err := s.db.ExecContext(ctx, `INSERT INTO auth_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`,
hashToken(tok), userID, now.UnixMilli(), exp.UnixMilli())
s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE expires_at<?`, now.UnixMilli())
return tok, exp, err
}
// UserByToken resolves a non-expired token (lookup by hash, so no timing leak on the secret).
func (s *Store) UserByToken(ctx context.Context, tok string) (User, error) {
var u User
var c int64
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.created_at FROM auth_tokens t JOIN users u ON u.id=t.user_id
WHERE t.token_hash=? AND t.expires_at>?`, hashToken(tok), time.Now().UTC().UnixMilli()).Scan(&u.ID, &u.Username, &c)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
u.CreatedAt = time.UnixMilli(c).UTC()
return u, err
}
func (s *Store) RevokeToken(ctx context.Context, tok string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token_hash=?`, hashToken(tok))
return err
}
// SessionOwnedBy returns ErrNotFound unless the session exists and belongs to userID (AllUsers = any).
func (s *Store) SessionOwnedBy(ctx context.Context, userID int64, id string) error {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE id=? AND (?<0 OR user_id=?)`, id, userID, userID).Scan(&n)
if err == nil && n == 0 {
return ErrNotFound
}
return err
}

BIN
preview/invites_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
preview/invites_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
preview/login_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
preview/login_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 KiB

After

Width:  |  Height:  |  Size: 534 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 588 KiB

Before After
Before After

View file

@ -1,3 +1,3 @@
/downloads/*.apk /downloads/*.apk
Content-Type: application/vnd.android.package-archive Content-Type: application/vnd.android.package-archive
Content-Disposition: attachment; filename="byteclaw-0.2.0.apk" Content-Disposition: attachment; filename="byteclaw-0.3.2.apk"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

BIN
site/assets/login_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
site/assets/login_dark.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
site/assets/login_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

@ -9,7 +9,7 @@
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="ByteClaw – selbst gehosteter KI-Assistent"> <meta property="og:title" content="ByteClaw – selbst gehosteter KI-Assistent">
<meta property="og:description" content="Go-Backend + Android-App. OpenAI, OpenRouter, Ollama, Dahl, GonkaRouter oder eigener Endpunkt."> <meta property="og:description" content="Go-Backend + Android-App. OpenAI, OpenRouter, Ollama, Dahl, GonkaRouter oder eigener Endpunkt.">
<meta property="og:image" content="https://byteclaw-app.web.app/assets/og.png"> <meta property="og:image" content="https://byteclaw-app-65807.web.app/assets/og.png">
<meta property="og:locale" content="de_DE"> <meta property="og:locale" content="de_DE">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="assets/icon.svg" type="image/svg+xml"> <link rel="icon" href="assets/icon.svg" type="image/svg+xml">
@ -32,7 +32,7 @@
<p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p> <p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p>
<p class="lead">Ein einzelnes Go-Binary mit SQLite spricht mit jedem OpenAI-kompatiblen Anbieter, nutzt Tools und streamt Antworten live in die native Android-App. Deine Schlüssel bleiben auf deinem Server.</p> <p class="lead">Ein einzelnes Go-Binary mit SQLite spricht mit jedem OpenAI-kompatiblen Anbieter, nutzt Tools und streamt Antworten live in die native Android-App. Deine Schlüssel bleiben auf deinem Server.</p>
<div class="cta"> <div class="cta">
<a class="btn btn-primary" href="downloads/byteclaw-0.2.0.apk" download>Android-App laden · v0.2.0 (1,9 MB)</a> <a class="btn btn-primary" href="https://git.85.190.126.179.sslip.io/crypto/byteclaw/releases/download/v0.3.2/byteclaw-0.3.2-release.apk">Android-App laden · v0.3.2 (1,9 MB)</a>
<a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a> <a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a>
</div> </div>
</div> </div>
@ -51,7 +51,8 @@
<article class="card"><h3>Abbrechen</h3><p>Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.</p></article> <article class="card"><h3>Abbrechen</h3><p>Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.</p></article>
<article class="card"><h3>Session-Zusammenfassung</h3><p>Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.</p></article> <article class="card"><h3>Session-Zusammenfassung</h3><p>Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.</p></article>
<article class="card"><h3>Anbieter pro Session</h3><p>Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.</p></article> <article class="card"><h3>Anbieter pro Session</h3><p>Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.</p></article>
<article class="card"><h3>Sichere Defaults</h3><p>Token-Pflicht, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.</p></article> <article class="card"><h3>Konten &amp; Login</h3><p>Registrierung und Anmeldung in der App – jeder Nutzer hat eigene, getrennte Chats. Passwörter als bcrypt-Hash, Tokens widerrufbar.</p></article>
<article class="card"><h3>Sichere Defaults</h3><p>Login-Pflicht, Rate-Limit, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.</p></article>
<article class="card"><h3>ARM &amp; Termux</h3><p>Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.</p></article> <article class="card"><h3>ARM &amp; Termux</h3><p>Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.</p></article>
</div> </div>
</section> </section>
@ -64,7 +65,7 @@
<figure><div class="phone"><picture><source srcset="assets/chat_provider_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_provider_dark.png" alt="Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter pro Session</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/chat_provider_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_provider_dark.png" alt="Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter pro Session</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/providers_dark.webp" type="image/webp"><img loading="lazy" src="assets/providers_dark.png" alt="Einstellungen mit Anbieter- und Modellauswahl, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter &amp; Modell</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/providers_dark.webp" type="image/webp"><img loading="lazy" src="assets/providers_dark.png" alt="Einstellungen mit Anbieter- und Modellauswahl, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter &amp; Modell</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/sessions_search_dark.webp" type="image/webp"><img loading="lazy" src="assets/sessions_search_dark.png" alt="Sessions durchsuchen, dunkles Design" width="450" height="1000"></picture></div><figcaption>Sessions suchen &amp; umbenennen</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/sessions_search_dark.webp" type="image/webp"><img loading="lazy" src="assets/sessions_search_dark.png" alt="Sessions durchsuchen, dunkles Design" width="450" height="1000"></picture></div><figcaption>Sessions suchen &amp; umbenennen</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/settings_light.webp" type="image/webp"><img loading="lazy" src="assets/settings_light.png" alt="Einstellungen mit Server-URL und Token, helles Design" width="450" height="1000"></picture></div><figcaption>Verbindung</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/login_light.webp" type="image/webp"><img loading="lazy" src="assets/login_light.png" alt="Anmelde- und Registrierungsbildschirm, helles Design" width="450" height="1000"></picture></div><figcaption>Anmelden &amp; Registrieren</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/chat_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_dark.png" alt="Chat, dunkles Design" width="450" height="1000"></picture></div><figcaption>Dunkles Design</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/chat_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_dark.png" alt="Chat, dunkles Design" width="450" height="1000"></picture></div><figcaption>Dunkles Design</figcaption></figure>
</div> </div>
</section> </section>
@ -88,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \
<footer class="footer"> <footer class="footer">
<p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p> <p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p>
<p class="muted">Screenshots zeigen Demo-Daten. APK v0.2.0 · Android 8+ · signiert.</p> <p class="muted">Screenshots zeigen Demo-Daten. APK v0.3.2 · Android 8+ · signiert.</p>
</footer> </footer>
<script> <script>
document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{ document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{