Implement OAuth2 PKCE auth, discovery, and mobile API client
Journal server (Phase 1.4 + 1.5): - Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema - Implement GET /oauth/authorize with PKCE flow and login redirect - Implement POST /oauth/token (authorization_code + refresh_token grants) - Add validateBearerToken() + getAuthenticatedUser() middleware - Seed trails-cool-mobile as trusted OAuth client on server startup - Add GET /.well-known/trails-cool discovery endpoint - Add returnTo support to login page and magic link verify - Add @trails-cool/api workspace dependency to journal Mobile app (Phase 1.5 + 1.6): - Login screen with server URL input and discovery validation - OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes - Token storage in expo-secure-store with auto-refresh on 401 - API client with bearer token injection and typed errors - Server URL persistence with localhost default in dev mode - API version compatibility check on app foreground - Log out + switch server on Profile tab - iOS ATS exception for local networking Tests: - PKCE crypto verification, OAuthError, token generation - Discovery endpoint response shape - API version semver compatibility - API client error types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
998db213d6
commit
1ae406a8aa
25 changed files with 1402 additions and 134 deletions
|
|
@ -17,6 +17,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: "cool.trails.app",
|
||||
infoPlist: {
|
||||
NSAppTransportSecurity: {
|
||||
NSAllowsLocalNetworking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
|
|
@ -28,5 +33,5 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||
web: {
|
||||
favicon: "./assets/favicon.png",
|
||||
},
|
||||
plugins: ["expo-router"],
|
||||
plugins: ["expo-router", "expo-secure-store", "expo-web-browser"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,70 @@
|
|||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { API_VERSION } from "@trails-cool/api";
|
||||
import { logout } from "../../lib/auth";
|
||||
import { getServerUrl, clearServerUrl } from "../../lib/server-config";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getServerUrl().then(setServerUrl);
|
||||
}, []);
|
||||
|
||||
const handleSwitchServer = () => {
|
||||
Alert.alert(
|
||||
"Switch Server",
|
||||
"This will log you out and clear local data. Continue?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Switch",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await logout();
|
||||
await clearServerUrl();
|
||||
router.replace("/login");
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Profile</Text>
|
||||
<Text style={styles.server}>{serverUrl}</Text>
|
||||
<Text style={styles.version}>API v{API_VERSION}</Text>
|
||||
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Text style={styles.logoutText}>Log Out</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={handleSwitchServer}>
|
||||
<Text style={styles.link}>Switch Server</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
|
||||
text: { fontSize: 18, color: "#666" },
|
||||
version: { fontSize: 12, color: "#999", marginTop: 8 },
|
||||
server: { fontSize: 14, color: "#999", marginTop: 4 },
|
||||
version: { fontSize: 12, color: "#999", marginTop: 4 },
|
||||
logoutButton: {
|
||||
marginTop: 32,
|
||||
backgroundColor: "#e5e5e5",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
logoutText: { fontSize: 16, color: "#333" },
|
||||
link: { color: "#4A6B40", fontSize: 14, marginTop: 16 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
import { Stack } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Stack, router } from "expo-router";
|
||||
import { isAuthenticated } from "../lib/auth";
|
||||
import { startVersionCheck } from "../lib/version-check";
|
||||
|
||||
export default function RootLayout() {
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
isAuthenticated().then((authed) => {
|
||||
if (!authed) {
|
||||
router.replace("/login");
|
||||
} else {
|
||||
startVersionCheck();
|
||||
}
|
||||
setChecked(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!checked) return null;
|
||||
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
|
|
|
|||
164
apps/mobile/app/login.tsx
Normal file
164
apps/mobile/app/login.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useState } from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
setServerUrl,
|
||||
fetchDiscovery,
|
||||
isApiVersionCompatible,
|
||||
ServerConfigError,
|
||||
} from "../lib/server-config";
|
||||
import { login } from "../lib/auth";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [serverUrl, setServerUrlState] = useState(
|
||||
__DEV__ ? "http://localhost:3000" : "https://trails.cool",
|
||||
);
|
||||
const [showCustomServer, setShowCustomServer] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const url = serverUrl.replace(/\/+$/, "");
|
||||
|
||||
// Validate server via discovery endpoint
|
||||
const discovery = await fetchDiscovery(url);
|
||||
|
||||
if (!isApiVersionCompatible(discovery.apiVersion)) {
|
||||
setError(
|
||||
`This app requires API v${discovery.apiVersion.split(".")[0]}.x but the server runs v${discovery.apiVersion}. Please update the app.`,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the validated server URL
|
||||
await setServerUrl(url);
|
||||
|
||||
// Start OAuth2 PKCE login
|
||||
const success = await login();
|
||||
if (success) {
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
setError("Login was cancelled or failed. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[Login] Error:", message, err);
|
||||
if (err instanceof ServerConfigError) {
|
||||
setError(`Could not connect to server: ${err.message}`);
|
||||
} else {
|
||||
setError(`Connection failed: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>trails.cool</Text>
|
||||
<Text style={styles.subtitle}>Sign in to your Journal</Text>
|
||||
|
||||
{showCustomServer && (
|
||||
<View style={styles.serverInput}>
|
||||
<Text style={styles.label}>Server URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrlState}
|
||||
placeholder="https://trails.cool"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{!showCustomServer && (
|
||||
<TouchableOpacity onPress={() => setShowCustomServer(true)}>
|
||||
<Text style={styles.link}>Connect to a different server</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "700",
|
||||
color: "#4A6B40",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
marginBottom: 32,
|
||||
},
|
||||
serverInput: {
|
||||
width: "100%",
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ccc",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#4A6B40",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
link: {
|
||||
color: "#4A6B40",
|
||||
fontSize: 14,
|
||||
},
|
||||
error: {
|
||||
color: "#c00",
|
||||
fontSize: 14,
|
||||
marginTop: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
24
apps/mobile/lib/__tests__/api-client.test.ts
Normal file
24
apps/mobile/lib/__tests__/api-client.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ApiError, NetworkError } from "../api-client";
|
||||
|
||||
describe("ApiError", () => {
|
||||
it("has status, code, and message", () => {
|
||||
const err = new ApiError(404, "NOT_FOUND", "Route not found");
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.code).toBe("NOT_FOUND");
|
||||
expect(err.message).toBe("Route not found");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkError", () => {
|
||||
it("has default message", () => {
|
||||
const err = new NetworkError();
|
||||
expect(err.message).toBe("Network request failed");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("accepts custom message", () => {
|
||||
const err = new NetworkError("Timeout");
|
||||
expect(err.message).toBe("Timeout");
|
||||
});
|
||||
});
|
||||
19
apps/mobile/lib/__tests__/server-config.test.ts
Normal file
19
apps/mobile/lib/__tests__/server-config.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { isApiVersionCompatible } from "../server-config";
|
||||
|
||||
describe("isApiVersionCompatible", () => {
|
||||
it("compatible when major versions match", () => {
|
||||
expect(isApiVersionCompatible("1.0.0")).toBe(true);
|
||||
expect(isApiVersionCompatible("1.2.3")).toBe(true);
|
||||
expect(isApiVersionCompatible("1.99.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("incompatible when major versions differ", () => {
|
||||
expect(isApiVersionCompatible("2.0.0")).toBe(false);
|
||||
expect(isApiVersionCompatible("0.9.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles malformed versions gracefully", () => {
|
||||
expect(isApiVersionCompatible("")).toBe(false);
|
||||
expect(isApiVersionCompatible("abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
127
apps/mobile/lib/api-client.ts
Normal file
127
apps/mobile/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { getServerUrl } from "./server-config";
|
||||
import { getAccessToken, refreshTokens, clearTokens } from "./auth";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(message = "Network request failed") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API client with bearer token injection and automatic 401 refresh.
|
||||
*/
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const serverUrl = await getServerUrl();
|
||||
const baseUrl = `${serverUrl}/api/v1`;
|
||||
const url = `${baseUrl}${path}`;
|
||||
|
||||
const token = await getAccessToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string> ?? {}),
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, { ...options, headers });
|
||||
} catch (err) {
|
||||
throw new NetworkError((err as Error).message);
|
||||
}
|
||||
|
||||
// Auto-refresh on 401
|
||||
if (resp.status === 401 && token) {
|
||||
const refreshed = await refreshTokens();
|
||||
if (refreshed) {
|
||||
const newToken = await getAccessToken();
|
||||
headers["Authorization"] = `Bearer ${newToken}`;
|
||||
try {
|
||||
resp = await fetch(url, { ...options, headers });
|
||||
} catch (err) {
|
||||
throw new NetworkError((err as Error).message);
|
||||
}
|
||||
} else {
|
||||
await clearTokens();
|
||||
throw new ApiError(401, "UNAUTHORIZED", "Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
resp.status,
|
||||
body.code ?? "UNKNOWN",
|
||||
body.error ?? `Request failed with status ${resp.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
export function listRoutes(cursor?: string, limit = 20) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return request<unknown>(`/routes?${params}`);
|
||||
}
|
||||
|
||||
export function getRoute(id: string) {
|
||||
return request<unknown>(`/routes/${id}`);
|
||||
}
|
||||
|
||||
export function createRoute(data: { name: string; description?: string; gpx?: string }) {
|
||||
return request<unknown>("/routes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRoute(id: string, data: { name?: string; description?: string; gpx?: string }) {
|
||||
return request<unknown>(`/routes/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Activities ---
|
||||
|
||||
export function listActivities(cursor?: string, limit = 20) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return request<unknown>(`/activities?${params}`);
|
||||
}
|
||||
|
||||
export function getActivity(id: string) {
|
||||
return request<unknown>(`/activities/${id}`);
|
||||
}
|
||||
|
||||
export function createActivity(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
gpx?: string;
|
||||
routeId?: string;
|
||||
startedAt?: string;
|
||||
duration?: number;
|
||||
distance?: number;
|
||||
}) {
|
||||
return request<unknown>("/activities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
142
apps/mobile/lib/auth.ts
Normal file
142
apps/mobile/lib/auth.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import * as SecureStore from "expo-secure-store";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { getServerUrl } from "./server-config";
|
||||
|
||||
const STORE_KEY_ACCESS_TOKEN = "access_token";
|
||||
const STORE_KEY_REFRESH_TOKEN = "refresh_token";
|
||||
const CLIENT_ID = "trails-cool-mobile";
|
||||
const REDIRECT_URI = "trailscool://auth/callback";
|
||||
|
||||
// --- PKCE helpers ---
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
const bytes = Crypto.getRandomBytes(32);
|
||||
return uint8ToBase64Url(bytes);
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const hash = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
verifier,
|
||||
{ encoding: Crypto.CryptoEncoding.BASE64 },
|
||||
);
|
||||
// Convert standard base64 to base64url
|
||||
return hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function uint8ToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
// --- Token storage ---
|
||||
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(STORE_KEY_ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
export async function getRefreshToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(STORE_KEY_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
async function storeTokens(accessToken: string, refreshToken: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(STORE_KEY_ACCESS_TOKEN, accessToken);
|
||||
await SecureStore.setItemAsync(STORE_KEY_REFRESH_TOKEN, refreshToken);
|
||||
}
|
||||
|
||||
export async function clearTokens(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(STORE_KEY_ACCESS_TOKEN);
|
||||
await SecureStore.deleteItemAsync(STORE_KEY_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const token = await getAccessToken();
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
// --- Login flow ---
|
||||
|
||||
export async function login(): Promise<boolean> {
|
||||
const serverUrl = await getServerUrl();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
|
||||
const authorizeUrl = new URL(`${serverUrl}/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
|
||||
authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
const result = await WebBrowser.openAuthSessionAsync(
|
||||
authorizeUrl.toString(),
|
||||
REDIRECT_URI,
|
||||
);
|
||||
|
||||
if (result.type !== "success") return false;
|
||||
|
||||
const callbackUrl = new URL(result.url);
|
||||
const code = callbackUrl.searchParams.get("code");
|
||||
if (!code) return false;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenUrl = `${serverUrl}/oauth/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const resp = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!resp.ok) return false;
|
||||
|
||||
const tokens = await resp.json();
|
||||
await storeTokens(tokens.access_token, tokens.refresh_token);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Token refresh ---
|
||||
|
||||
export async function refreshTokens(): Promise<boolean> {
|
||||
const serverUrl = await getServerUrl();
|
||||
const refreshToken = await getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
|
||||
const resp = await fetch(`${serverUrl}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
await clearTokens();
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokens = await resp.json();
|
||||
await storeTokens(tokens.access_token, tokens.refresh_token);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Logout ---
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await clearTokens();
|
||||
}
|
||||
100
apps/mobile/lib/server-config.ts
Normal file
100
apps/mobile/lib/server-config.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import * as SecureStore from "expo-secure-store";
|
||||
import { API_VERSION } from "@trails-cool/api";
|
||||
|
||||
const STORE_KEY_SERVER_URL = "server_url";
|
||||
const DEFAULT_SERVER_URL = __DEV__ ? "http://localhost:3000" : "https://trails.cool";
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
apiVersion: string;
|
||||
instanceName: string;
|
||||
apiBaseUrl: string;
|
||||
tileUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored server URL, or the default.
|
||||
*/
|
||||
export async function getServerUrl(): Promise<string> {
|
||||
const stored = await SecureStore.getItemAsync(STORE_KEY_SERVER_URL);
|
||||
return stored ?? DEFAULT_SERVER_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new server URL.
|
||||
*/
|
||||
export async function setServerUrl(url: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(STORE_KEY_SERVER_URL, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stored server URL (resets to default).
|
||||
*/
|
||||
export async function clearServerUrl(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(STORE_KEY_SERVER_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and validate the discovery endpoint for a server URL.
|
||||
* Returns the parsed discovery response or throws.
|
||||
*/
|
||||
export async function fetchDiscovery(serverUrl: string): Promise<DiscoveryResponse> {
|
||||
const url = `${serverUrl.replace(/\/+$/, "")}/.well-known/trails-cool`;
|
||||
console.log("[Discovery] Fetching:", url);
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
resp = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
} catch (err) {
|
||||
console.error("[Discovery] Fetch failed:", err);
|
||||
throw new ServerConfigError("network_error", (err as Error).message);
|
||||
}
|
||||
console.log("[Discovery] Response status:", resp.status);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new ServerConfigError(
|
||||
"discovery_failed",
|
||||
`Server returned ${resp.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (!data.apiVersion || !data.instanceName || !data.apiBaseUrl) {
|
||||
throw new ServerConfigError(
|
||||
"invalid_discovery",
|
||||
"Server returned an invalid discovery response",
|
||||
);
|
||||
}
|
||||
|
||||
return data as DiscoveryResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the major version number from a semver string.
|
||||
*/
|
||||
function majorVersion(semver: string): number {
|
||||
const match = semver.match(/^(\d+)/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server's API version is compatible with this app.
|
||||
* Compares major versions — the server must match the app's major version.
|
||||
*/
|
||||
export function isApiVersionCompatible(serverVersion: string): boolean {
|
||||
return majorVersion(serverVersion) === majorVersion(API_VERSION);
|
||||
}
|
||||
|
||||
export class ServerConfigError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
31
apps/mobile/lib/version-check.ts
Normal file
31
apps/mobile/lib/version-check.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Alert, AppState } from "react-native";
|
||||
import { getServerUrl, fetchDiscovery, isApiVersionCompatible } from "./server-config";
|
||||
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* Start listening for app foreground events to re-check API version.
|
||||
* Shows a non-blocking alert if the server version has drifted.
|
||||
*/
|
||||
export function startVersionCheck() {
|
||||
if (listening) return;
|
||||
listening = true;
|
||||
|
||||
AppState.addEventListener("change", async (state) => {
|
||||
if (state !== "active") return;
|
||||
|
||||
try {
|
||||
const serverUrl = await getServerUrl();
|
||||
const discovery = await fetchDiscovery(serverUrl);
|
||||
if (!isApiVersionCompatible(discovery.apiVersion)) {
|
||||
Alert.alert(
|
||||
"Update Available",
|
||||
"The server has been updated. Please update the app for the best experience.",
|
||||
[{ text: "OK" }],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — network may be unavailable
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -25,8 +25,11 @@
|
|||
"@trails-cool/map-core": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"expo": "~55.0.14",
|
||||
"expo-crypto": "~55.0.14",
|
||||
"expo-router": "~55.0.12",
|
||||
"expo-secure-store": "~55.0.13",
|
||||
"expo-status-bar": "~55.0.5",
|
||||
"expo-web-browser": "~55.0.14",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
|
|
@ -34,7 +37,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.2.14",
|
||||
"jest-expo": "^55.0.15",
|
||||
"typescript": "~5.9.2"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue