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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions core-mobile-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Shared Core Fixes Backlog

These are non-blocking fixes that should benefit both the web app and mobile
app. Required mobile-enabling backend changes belong in this branch directly,
not in this backlog.

## Permission Hardening

- Audit chat server actions for authorization symmetry with HTTP routes. The new
mobile routes check thread/message ownership before mutation; equivalent server
actions should be reviewed so direct calls cannot delete or rename resources
outside the current user.
- Add focused tests around destructive chat operations: delete message, delete
thread, delete all threads, rename thread, and regenerate from message.

## Error Contracts

- Normalize API error response bodies across web and mobile routes. Some routes
return plain text responses while others return `{ error }` or `{ message }`.
A shared shape would simplify web UI handling and native clients.

## Storage UX

- Surface storage misconfiguration consistently in web flows before upload starts,
matching the structured details returned by `/api/storage/info`.
59 changes: 59 additions & 0 deletions docs/mobile-api-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Mobile API Contract

This document records the backend contract used by `expo-app`. It is intentionally
HTTP-focused so native clients do not depend on Next.js server actions.

## Auth

- `GET /api/mobile/auth/config`
- Returns email/password availability, sign-up availability, first-user state,
enabled social providers, backend base URL, and auth base path.
- `GET/POST /api/auth/[...all]`
- Better Auth handler with Expo plugin support.
- Native clients use `@better-auth/expo/client` with SecureStore-backed cookie
persistence.

## Threads

- `GET /api/thread`
- Returns the signed-in user's thread list with `lastMessageAt`.
- `GET /api/thread/:id`
- Returns a thread plus messages after verifying ownership.
- `PATCH /api/thread/:id`
- Body: `{ "title": string }`.
- Renames a thread after verifying ownership.
- `DELETE /api/thread/:id`
- Deletes a thread after verifying ownership.

## Chat

- `POST /api/chat`
- Persistent UI message stream endpoint.
- Body includes `id`, `message`, `chatModel`, `toolChoice`, `mentions`,
`allowedMcpServers`, `allowedAppDefaultToolkit`, `imageTool`, and
`attachments`.
- `POST /api/chat/temporary`
- Temporary UI message stream endpoint.
- Body: `{ "messages": UIMessage[], "chatModel"?: ChatModel,
"instructions"?: string }`.
- `POST /api/chat/title`
- Streams a generated title and upserts it into the thread.
- `DELETE /api/chat/message/:id`
- Deletes a message after verifying ownership through its thread.
- `POST /api/chat/regenerate`
- Body: `{ "messageId": string }`.
- Deletes the target message and later messages in the same thread after
verifying ownership.
- `GET /api/chat/models`
- Returns enabled provider/model metadata.

## Storage

- `POST /api/storage/upload`
- Multipart fallback upload endpoint. Field name: `file`.

## Advanced Capabilities

MCP, agents, workflows, default tools, image generation, storage, rate limits,
and provider execution remain server-owned. Mobile clients pass selection state
through the chat request body and render returned `UIMessage.parts`.
1 change: 1 addition & 0 deletions expo-app/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EXPO_PUBLIC_BACKEND_URL=http://localhost:3000
9 changes: 9 additions & 0 deletions expo-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
.expo/
dist/

# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli

expo-env.d.ts
# @end expo-cli
18 changes: 18 additions & 0 deletions expo-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Better Chatbot Expo App

Native iOS and Android client for the Better Chatbot backend in this repository.

## Setup

1. Copy `.env.example` to `.env`.
2. Set `EXPO_PUBLIC_BACKEND_URL` to the reachable Next.js backend URL.
- iOS simulator can usually use `http://localhost:3000`.
- Android emulator usually needs `http://10.0.2.2:3000`.
- Physical devices need a LAN or tunneled URL.
3. Run `pnpm install` inside `expo-app`.
4. Start the backend with `NO_HTTPS=1 pnpm dev` from the repository root.
5. Start mobile with `pnpm start` inside `expo-app`.

The app uses Better Auth Expo session storage, native auth screens, persistent
chat, temporary chat, model selection, thread list actions, message actions,
and file/image attachment upload through the existing backend storage APIs.
51 changes: 51 additions & 0 deletions expo-app/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"expo": {
"name": "Better Chatbot",
"slug": "better-chatbot",
"scheme": "betterchatbot",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "app.betterchatbot.mobile",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "app.betterchatbot.mobile",
"adaptiveIcon": {
"backgroundColor": "#0a0a0a"
}
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-web-browser",
[
"expo-document-picker",
{
"iCloudContainerEnvironment": "Development"
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow Better Chatbot to attach images from your photo library.",
"microphonePermission": false
}
],
"expo-font"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "f4d9cc53-89d8-45a2-bbb0-cf14c690edd5"
}
}
}
}
10 changes: 10 additions & 0 deletions expo-app/app/(auth)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Stack } from "expo-router";

export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="sign-in" />
<Stack.Screen name="sign-up" />
</Stack>
);
}
177 changes: 177 additions & 0 deletions expo-app/app/(auth)/sign-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { useQuery } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { Link, router } from "expo-router";
import { Github, Mail } from "lucide-react-native";
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
View,
} from "react-native";
import { ErrorBanner, Button, Field, Screen } from "@/components/ui";
import { getAuthConfig } from "@/lib/api";
import { authClient } from "@/lib/auth";
import { colors, spacing, typography } from "@/theme";

export default function SignInScreen() {
const config = useQuery({
queryKey: ["auth-config"],
queryFn: getAuthConfig,
});
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

async function signInEmail() {
setLoading(true);
setError(null);
try {
const result = await authClient.signIn.email({ email, password });
if (result?.error) {
throw new Error(result.error.message || "Could not sign in");
}
router.replace("/");
} catch (err: any) {
setError(err.message || "Could not sign in");
} finally {
setLoading(false);
}
}

async function signInSocial(provider: "github" | "google" | "microsoft") {
setLoading(true);
setError(null);
try {
const result = await authClient.signIn.social({
provider,
callbackURL: Linking.createURL("/"),
});
if (result?.error) {
throw new Error(
result.error.message || "Could not start social sign in",
);
}
} catch (err: any) {
setError(err.message || "Could not start social sign in");
} finally {
setLoading(false);
}
}

const providers = config.data?.socialProviders;

return (
<Screen>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.wrap}
>
<View style={styles.header}>
<Text style={styles.title}>Better Chatbot</Text>
<Text style={styles.subtitle}>Sign in to continue your chats.</Text>
</View>
<ErrorBanner message={error} />
{config.data?.emailAndPasswordEnabled !== false ? (
<View style={styles.form}>
<Field
autoComplete="email"
keyboardType="email-address"
label="Email"
onChangeText={setEmail}
value={email}
/>
<Field
autoComplete="password"
label="Password"
onChangeText={setPassword}
secureTextEntry
value={password}
/>
<Button
disabled={!email || !password}
icon={Mail}
label="Sign in"
loading={loading}
onPress={signInEmail}
/>
</View>
) : null}
<View style={styles.social}>
{providers?.google ? (
<Button
label="Continue with Google"
loading={loading}
onPress={() => signInSocial("google")}
variant="secondary"
/>
) : null}
{providers?.github ? (
<Button
icon={Github}
label="Continue with GitHub"
loading={loading}
onPress={() => signInSocial("github")}
variant="secondary"
/>
) : null}
{providers?.microsoft ? (
<Button
label="Continue with Microsoft"
loading={loading}
onPress={() => signInSocial("microsoft")}
variant="secondary"
/>
) : null}
</View>
{config.data?.signUpEnabled !== false ? (
<Text style={styles.footer}>
New here?{" "}
<Link href="/sign-up" style={styles.link}>
Create account
</Link>
</Text>
) : null}
</KeyboardAvoidingView>
</Screen>
);
}

const styles = StyleSheet.create({
wrap: {
flex: 1,
justifyContent: "center",
width: "100%",
maxWidth: 420,
alignSelf: "center",
gap: 20,
paddingHorizontal: spacing.screenX,
},
header: {
gap: 6,
},
title: {
color: colors.foreground,
...typography.title,
},
subtitle: {
color: colors.mutedForeground,
...typography.body,
},
form: {
gap: 14,
},
social: {
gap: 10,
},
footer: {
color: colors.mutedForeground,
textAlign: "center",
},
link: {
color: colors.foreground,
fontWeight: "700",
},
});
Loading
Loading