diff --git a/.gitignore b/.gitignore
index 7547636..522fea4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,3 @@
*.db
config.yaml
.env
-
-.kotlin/
-.netlify/
-.firebase/
diff --git a/README.md b/README.md
index f5cde10..3723a45 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github.
## Sichere Defaults
-- 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.
+- 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. `allow_registration` (Standard an). 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`.
- 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.
@@ -164,8 +164,6 @@ 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.
-Ö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
- CI (GitHub Actions) für Go + Android
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 5bef1eb..39e2a6b 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -31,8 +31,8 @@ android {
applicationId = "dev.byteclaw.client"
minSdk = 26
targetSdk = 35
- versionCode = 5
- versionName = "0.3.2"
+ versionCode = 3
+ versionName = "0.3.0"
}
signingConfigs {
diff --git a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt
index 269cd6c..7b9d59a 100644
--- a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt
+++ b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt
@@ -69,7 +69,7 @@ class MainActivity : ComponentActivity() {
}
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,
+ LoginScreen(state, onAuthenticate = { u, n, p, r -> vm.authenticate(u, n, p, r) },
onAdminToken = { u, t -> vm.useAdminToken(u, t) })
}
composable("chat") {
@@ -93,8 +93,7 @@ class MainActivity : ComponentActivity() {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
- onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout,
- onLoadInvites = { vm.loadInvites() }, onCreateInvite = { n, h -> vm.createInvite(n, h) }, onRevokeInvite = { vm.revokeInvite(it) })
+ onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout)
}
}
}
diff --git a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt
index 3c8a020..b3c89ac 100644
--- a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt
+++ b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt
@@ -60,18 +60,6 @@ class ByteClawApi(private val settings: ServerSettings) {
suspend fun messages(sessionId: String): List =
json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build()))
- suspend fun invites(): List =
- 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) {
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
}
@@ -144,11 +132,10 @@ class ByteClawApi(private val settings: ServerSettings) {
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 =
+ suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean): 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 body = authJson.encodeToString(Credentials.serializer(), Credentials(username.trim(), password)).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()
@@ -157,18 +144,6 @@ class ByteClawApi(private val settings: ServerSettings) {
}
}
- /** 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 shared = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
diff --git a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt
index 8eae52b..399ac78 100644
--- a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt
+++ b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt
@@ -94,29 +94,7 @@ object ProviderPresets {
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 = emptyList(),
-)
-
-@Serializable
-data class InviteRequest(@SerialName("max_uses") val maxUses: Int = 1, @SerialName("expires_in_hours") val expiresInHours: Int = 0)
+data class Credentials(val username: String, val password: String)
@Serializable
data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false)
@@ -129,10 +107,6 @@ 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, . _ -)"
diff --git a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt
index f48ff42..8105c73 100644
--- a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt
+++ b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt
@@ -20,7 +20,7 @@ class SettingsStore(context: Context) {
)
fun load() = ServerSettings(
- serverUrl = prefs.getString(KEY_URL, null)?.takeIf { it.isNotBlank() } ?: DEFAULT_SERVER_URL,
+ serverUrl = prefs.getString(KEY_URL, "") ?: "",
token = prefs.getString(KEY_TOKEN, "") ?: "",
username = prefs.getString(KEY_USER, "") ?: "",
)
@@ -30,11 +30,9 @@ class SettingsStore(context: Context) {
.putString(KEY_USER, s.username.trim()).apply()
}
- companion object {
- /** Public ByteClaw server, prefilled on the login screen (user can change it). */
- 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"
+ private companion object {
+ const val KEY_URL = "server_url"
+ const val KEY_TOKEN = "token"
+ const val KEY_USER = "username"
}
}
diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt
index c571f78..780339a 100644
--- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt
+++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt
@@ -4,7 +4,6 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dev.byteclaw.client.data.ActiveProvider
-import dev.byteclaw.client.data.Invite
import dev.byteclaw.client.data.ByteClawApi
import dev.byteclaw.client.data.AuthErrors
import dev.byteclaw.client.data.UnauthorizedException
@@ -45,13 +44,6 @@ data class UiState(
/** 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 = emptyList(),
- val inviteStatus: String? = null,
) {
/** What the current chat effectively uses: session override, else the global active provider. */
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
@@ -87,40 +79,13 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
}
/** 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 = "") {
+ fun authenticate(serverUrl: String, username: String, password: String, register: Boolean) {
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) }
+ runCatching { ByteClawApi.authenticate(serverUrl, username, password, register) }
.onSuccess { r ->
store.save(ServerSettings(serverUrl, r.token, r.user.username))
_state.update { UiState(settings = store.load()) }
@@ -301,7 +266,6 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
}
fun loadProviders() = viewModelScope.launch {
- runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } }
runCatching { api().providers() }
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
.onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }
diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/InviteSection.kt b/android/app/src/main/java/dev/byteclaw/client/ui/InviteSection.kt
deleted file mode 100644
index b6f916d..0000000
--- a/android/app/src/main/java/dev/byteclaw/client/ui/InviteSection.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-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(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") }
- }
-}
diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt b/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt
index bad7abe..5580298 100644
--- a/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt
+++ b/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt
@@ -18,8 +18,7 @@ import dev.byteclaw.client.data.ByteClawApi
@Composable
fun LoginScreen(
state: UiState,
- onAuthenticate: (url: String, user: String, pass: String, register: Boolean, invite: String) -> Unit,
- onCheckMode: (url: String) -> Unit = {},
+ onAuthenticate: (url: String, user: String, pass: String, register: Boolean) -> Unit,
onAdminToken: (url: String, token: String) -> Unit = { _, _ -> },
initialRegister: Boolean = false,
initialUser: String? = null,
@@ -31,9 +30,6 @@ fun LoginScreen(
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
@@ -69,22 +65,14 @@ fun LoginScreen(
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 (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2)),
+ onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register) },
) {
if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" })
diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt
index f58002c..74e7c73 100644
--- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt
+++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt
@@ -271,9 +271,6 @@ fun SettingsScreen(
onSelectProvider: (String, String) -> Unit = { _, _ -> },
initialStatus: String? = null,
onLogout: () -> Unit = {},
- onLoadInvites: () -> Unit = {},
- onCreateInvite: (Int, Int) -> Unit = { _, _ -> },
- onRevokeInvite: (String) -> Unit = {},
) {
var status by remember { mutableStateOf(initialStatus) }
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
@@ -299,15 +296,7 @@ fun SettingsScreen(
}
status?.let { item { Text(it) } }
if (state.settings.isConfigured) {
- 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)
- }
- }
+ item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
}
}
}
@@ -381,7 +370,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia
Box {
AssistChip(
onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) },
- enabled = state.settings.isConfigured && !state.busy && state.isAdmin,
+ enabled = state.settings.isConfigured && !state.busy,
modifier = Modifier.height(28.dp).widthIn(max = 230.dp),
)
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt
index cdd9304..65a1d81 100644
--- a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt
+++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt
@@ -11,7 +11,6 @@ import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.SessionsScreen
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.ProviderUi
import dev.byteclaw.client.data.ProviderInfo
@@ -44,7 +43,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chat() = snap("chat") {
ChatScreen(
- state = UiState(isAdmin = true,
+ state = UiState(
settings = settings, sessionId = "demo", busy = true,
lines = listOf(
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
@@ -69,7 +68,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chatProvider() = snap("chat_provider") {
ChatScreen(
- state = UiState(isAdmin = true,
+ state = UiState(
settings = settings, sessionId = "demo", provider = demoProviders,
sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"),
lines = listOf(
@@ -84,7 +83,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chatMarkdown() = snap("chat_markdown") {
ChatScreen(
- state = UiState(isAdmin = true,
+ state = UiState(
settings = settings, sessionId = "demo", online = true,
lines = listOf(
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
@@ -97,7 +96,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun sessionsSearch() = snap("sessions_search") {
SessionsScreen(
- state = UiState(isAdmin = true,
+ state = UiState(
settings = settings,
sessions = listOf(
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
@@ -111,7 +110,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun sessions() = snap("sessions") {
SessionsScreen(
- state = UiState(isAdmin = true,
+ state = UiState(
settings = settings,
sessions = listOf(
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
@@ -127,14 +126,14 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun settings() = snap("settings") {
SettingsScreen(
- state = UiState(isAdmin = true, settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
+ state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
)
}
@Test fun providers() = snap("providers") {
SettingsScreen(
- state = UiState(isAdmin = true,
+ state = UiState(
settings = settings,
provider = ProviderUi(
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
@@ -153,25 +152,13 @@ class PreviewScreenshots(private val dark: Boolean) {
}
@Test fun login() = snap("login") {
- LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _, _ -> })
+ LoginScreen(state = UiState(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 = {},
+ state = UiState(settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
+ onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
)
}
}
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[0]_invites_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[0]_invites_light.png
deleted file mode 100644
index 174997e..0000000
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[0]_invites_light.png and /dev/null differ
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[1]_invites_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[1]_invites_dark.png
deleted file mode 100644
index 0afab55..0000000
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[1]_invites_dark.png and /dev/null differ
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png
index 86c8705..93d8bb0 100644
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png differ
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png
index 54b8da2..62c1ef8 100644
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png differ
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png
index 98f70db..48101b0 100644
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png differ
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png
index b8a72bf..87d9bd8 100644
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png differ
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png
index fdaadd9..9058377 100644
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png differ
diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png
index cce9ae3..0c61a8a 100644
Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png differ
diff --git a/cmd/byteclaw/main.go b/cmd/byteclaw/main.go
index b967ffa..0a34729 100644
--- a/cmd/byteclaw/main.go
+++ b/cmd/byteclaw/main.go
@@ -81,12 +81,7 @@ func run(cfgPath string, log *slog.Logger) error {
p, _, err := pm.ForSession(ctx, sid)
return p, err
}}
- 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,
+ srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), 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},
RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second}
diff --git a/config.example.yaml b/config.example.yaml
index b709482..d179374 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -3,10 +3,8 @@
# 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
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)
+allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account
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
data_dir: ./data
workspace: "" # default: /workspace; file tools cannot leave it
diff --git a/internal/config/config.go b/internal/config/config.go
index 5323366..794b94a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -75,19 +75,15 @@ type Tools 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"`
// 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 []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"`
Workspace string `yaml:"workspace" json:"workspace"`
LLM LLM `yaml:"llm" json:"llm"`
@@ -166,7 +162,6 @@ func applyEnv(c *Config, get func(string) string) {
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 != "" {
c.AllowedOrigins = nil
for _, o := range strings.Split(v, ",") {
@@ -180,29 +175,12 @@ 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.
func (c Config) Validate() error {
var errs []error
if c.AuthToken != "" && len(c.AuthToken) < 16 {
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 == "" {
errs = append(errs, errors.New("llm.base_url and llm.model are required"))
}
diff --git a/internal/server/admin_test.go b/internal/server/admin_test.go
deleted file mode 100644
index 544e89a..0000000
--- a/internal/server/admin_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-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)
- }
-}
diff --git a/internal/server/auth.go b/internal/server/auth.go
index 6070453..bd481cc 100644
--- a/internal/server/auth.go
+++ b/internal/server/auth.go
@@ -103,71 +103,14 @@ func (l *limiter) Reset(key string) {
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 {
+func 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 r.RemoteAddr
}
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 {
@@ -215,9 +158,8 @@ func (s *Server) auth(h http.Handler) http.Handler {
}
type credentials struct {
- Username string `json:"username"`
- Password string `json:"password"`
- InviteCode string `json:"invite_code,omitempty"`
+ Username string `json:"username"`
+ Password string `json:"password"`
}
func validUsername(u string) bool {
@@ -248,12 +190,11 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, cod
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
s.initAuth()
- mode := s.regMode()
- if mode == "closed" {
+ if !s.AllowRegistration {
writeErr(w, http.StatusForbidden, "registration disabled")
return
}
- ip := s.clientIP(r)
+ ip := clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
@@ -271,24 +212,13 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
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
- }
+ u, err := s.Store.CreateUser(r.Context(), c.Username, string(hash))
if errors.Is(err, store.ErrUserExists) {
writeErr(w, http.StatusConflict, "username already taken")
return
@@ -302,7 +232,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
s.initAuth()
- ip := s.clientIP(r)
+ ip := clientIP(r)
if s.limiter.Blocked(ip) {
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
@@ -352,15 +282,3 @@ func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool {
}
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"
-}
diff --git a/internal/server/clientip_test.go b/internal/server/clientip_test.go
deleted file mode 100644
index ae6379e..0000000
--- a/internal/server/clientip_test.go
+++ /dev/null
@@ -1,26 +0,0 @@
-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)
- }
-}
diff --git a/internal/server/extra.go b/internal/server/extra.go
index 8da25f3..1594c14 100644
--- a/internal/server/extra.go
+++ b/internal/server/extra.go
@@ -48,9 +48,6 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) {
- if !requireAdmin(w, r) {
- return
- }
if !s.needProviders(w) {
return
}
@@ -85,9 +82,6 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
// PUT /api/sessions/{id}/provider: per-session override; empty provider clears it.
func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
- if !requireAdmin(w, r) {
- return
- }
if !s.needProviders(w) {
return
}
@@ -154,15 +148,3 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
}
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
-}
diff --git a/internal/server/extra_test.go b/internal/server/extra_test.go
index bf9fdc3..37ad78a 100644
--- a/internal/server/extra_test.go
+++ b/internal/server/extra_test.go
@@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) {
pr, _, err := pm.ForSession(ctx, sid)
return pr, err
}
- s := &Server{Store: st, Token: tok, AllowRegistration: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm}
+ s := &Server{Store: st, Token: tok, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm}
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return ts, st
diff --git a/internal/server/invites.go b/internal/server/invites.go
deleted file mode 100644
index a83b0f4..0000000
--- a/internal/server/invites.go
+++ /dev/null
@@ -1,54 +0,0 @@
-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)
-}
diff --git a/internal/server/invites_test.go b/internal/server/invites_test.go
deleted file mode 100644
index 0eb5a1a..0000000
--- a/internal/server/invites_test.go
+++ /dev/null
@@ -1,145 +0,0 @@
-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)
- }
-}
diff --git a/internal/server/server.go b/internal/server/server.go
index 3fa1a91..5e2afb9 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -3,10 +3,9 @@ package server
import (
"context"
- "encoding/json"
+ "encoding/json"
"errors"
"log/slog"
- "net"
"net/http"
"net/url"
"strings"
@@ -37,12 +36,8 @@ type Server struct {
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
@@ -61,16 +56,10 @@ func (s *Server) Handler() http.Handler {
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.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/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("PUT /api/providers/active", s.setActiveProvider)
api.HandleFunc("GET /api/providers/{name}/models", s.listModels)
@@ -261,9 +250,6 @@ type chatReq struct {
func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) {
var a providers.Active
if req.SessionID == "" && req.Provider != "" && s.Providers != nil {
- if !principal(ctx).Admin {
- return "", errAdminOnly
- }
var err error
if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil {
return "", err
@@ -299,10 +285,6 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
return
}
id, err := s.startSession(r.Context(), req)
- if errors.Is(err, errAdminOnly) {
- writeErr(w, http.StatusForbidden, err.Error())
- return
- }
if err != nil {
if req.SessionID == "" && req.Provider != "" {
providerErr(w, err)
diff --git a/internal/store/invites.go b/internal/store/invites.go
deleted file mode 100644
index bc758d3..0000000
--- a/internal/store/invites.go
+++ /dev/null
@@ -1,163 +0,0 @@
-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 usesSelbst gehosteter, schlanker KI-Assistent – Go-Backend + Android-App.
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.
@@ -89,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \