node-api / server /README.md
taqarub's picture
Deploy Taqarub Node API server
cd2a4c2
|
Raw History Blame Contribute Delete
35.3 kB
# Backend Development Guide This guide covers server-side features including authentication, database, tRPC API, and integrations. **Only read this if your app needs these capabilities.** --- ## When Do You Need Backend? | Scenario | Backend Needed? | User Auth Required? | Solution | |----------|-----------------|---------------------|----------| | Data stays on device only | No | No | Use `AsyncStorage` | | Data syncs across devices | Yes | Yes | Database + tRPC | | User accounts / login | Yes | Yes | Manus OAuth | | AI-powered features | Yes | **Optional** | LLM Integration | | User uploads files | Yes | **Optional** | S3 Storage | | Server-side validation | Yes | **Optional** | tRPC procedures | > **Note:** Backend β‰  User Auth. You can run a backend with LLM/Storage/ImageGen capabilities without requiring user login β€” just use `publicProcedure` instead of `protectedProcedure`. User auth is only mandatory when you need to identify users or sync user-specific data. --- ## File Structure ``` server/ db.ts ← Query helpers (add database functions here) routers.ts ← tRPC procedures (add API routes here) storage.ts ← S3 storage helpers (can extend) _core/ ← Framework-level code (don't modify) drizzle/ schema.ts ← Database tables & types (add your tables here) relations.ts ← Table relationships migrations/ ← Auto-generated migrations shared/ types.ts ← Shared TypeScript types const.ts ← Shared constants _core/ ← Framework-level code (don't modify) lib/ trpc.ts ← tRPC client (can customize headers) _core/ ← Framework-level code (don't modify) hooks/ use-auth.ts ← Auth state hook (don't modify) tests/ *.test.ts ← Add your tests here ``` Only touch the files with "←" markers. Anything under `_core/` directories is framework-levelβ€”avoid editing unless you are extending the infrastructure. --- ## Authentication ### Overview The template uses **Manus OAuth** for user authentication. It works differently on native and web: | Platform | Auth Method | Token Storage | |----------|-------------|---------------| | iOS/Android | Bearer token | expo-secure-store | | Web | HTTP-only cookie | Browser cookie | ### Using the Auth Hook ```tsx import { useAuth } from "@/hooks/use-auth"; function MyScreen() { const { user, isAuthenticated, loading, logout } = useAuth(); if (loading) return <ActivityIndicator />; if (!isAuthenticated) { return <LoginButton />; } return ( <View> <ThemedText>Welcome, {user.name}</ThemedText> <Button title="Logout" onPress={logout} /> </View> ); } ``` ### User Object The `user` object contains: ```tsx interface User { id: number; openId: string; // Manus OAuth ID name: string | null; email: string | null; loginMethod: string; role: "user" | "admin"; lastSignedIn: Date; } ``` ### Login Flow (Native) 1. User taps Login button 2. `startOAuthLogin()` calls `Linking.openURL()` which opens Manus OAuth in the system browser 3. User authenticates 4. OAuth redirects to the app's deep link (`/oauth/callback`) with code/state params 5. App opens the callback handler 6. Callback exchanges code for session token 7. Token stored in SecureStore 8. User redirected to home ### Login Flow (Web) 1. User clicks Login button 2. Browser redirects to Manus OAuth 3. User authenticates 4. Redirect back with session cookie 5. Cookie automatically sent with requests ### Protected Routes Use `protectedProcedure` in tRPC to require authentication: ```tsx // server/routers.ts import { protectedProcedure } from "./_core/trpc"; export const appRouter = router({ myFeature: router({ getData: protectedProcedure.query(({ ctx }) => { // ctx.user is guaranteed to exist return db.getUserData(ctx.user.id); }), }), }); ``` ### Frontend: Handling Auth Errors protectedProcedure MUST HANDLE UNAUTHORIZED when user is not logged in. Always handle this in the frontend: ```tsx try { await trpc.someProtectedEndpoint.mutate(data); } catch (error) { if (error.data?.code === 'UNAUTHORIZED') { router.push('/login'); return; } throw error; } ``` --- ## Database ### Schema Definition Define your tables in `drizzle/schema.ts`: ```tsx import { int, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core"; // Users table (already exists) export const users = mysqlTable("users", { id: int("id").autoincrement().primaryKey(), openId: varchar("openId", { length: 64 }).notNull().unique(), name: text("name"), email: varchar("email", { length: 320 }), role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); // Add your tables export const items = mysqlTable("items", { id: int("id").autoincrement().primaryKey(), userId: int("userId").notNull(), title: varchar("title", { length: 255 }).notNull(), description: text("description"), completed: boolean("completed").default(false).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), }); // Export types export type User = typeof users.$inferSelect; export type Item = typeof items.$inferSelect; export type InsertItem = typeof items.$inferInsert; ``` ### Running Migrations After editing the schema, push changes to the database: ```bash pnpm db:push ``` This runs `drizzle-kit generate` and `drizzle-kit migrate`. ### Query Helpers Add database queries in `server/db.ts`: ```tsx import { eq } from "drizzle-orm"; import { getDb } from "./_core/db"; import { items, InsertItem } from "../drizzle/schema"; export async function getUserItems(userId: number) { const db = await getDb(); if (!db) return []; return db.select().from(items).where(eq(items.userId, userId)); } export async function createItem(data: InsertItem) { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(items).values(data); return result.insertId; } export async function updateItem(id: number, data: Partial<InsertItem>) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(items).set(data).where(eq(items.id, id)); } export async function deleteItem(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.delete(items).where(eq(items.id, id)); } ``` --- ## tRPC API ### Adding Routes Define API routes in `server/routers.ts`: ```tsx import { z } from "zod"; import { router, protectedProcedure, publicProcedure } from "./_core/trpc"; import * as db from "./db"; export const appRouter = router({ // Public route (no auth required) health: publicProcedure.query(() => ({ status: "ok" })), // Protected routes (auth required) items: router({ list: protectedProcedure.query(({ ctx }) => { return db.getUserItems(ctx.user.id); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(255), description: z.string().optional(), })) .mutation(({ ctx, input }) => { return db.createItem({ userId: ctx.user.id, title: input.title, description: input.description, }); }), update: protectedProcedure .input(z.object({ id: z.number(), title: z.string().min(1).max(255).optional(), completed: z.boolean().optional(), })) .mutation(({ input }) => { return db.updateItem(input.id, input); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(({ input }) => { return db.deleteItem(input.id); }), }), }); export type AppRouter = typeof appRouter; ``` ### Calling from Frontend Use tRPC hooks in your components: ```tsx import { trpc } from "@/lib/trpc"; function ItemList() { // Query const { data: items, isLoading, refetch } = trpc.items.list.useQuery(); // Mutation const createMutation = trpc.items.create.useMutation({ onSuccess: () => refetch(), }); const handleCreate = async () => { await createMutation.mutateAsync({ title: "New Item", description: "Description here", }); }; if (isLoading) return <ActivityIndicator />; return ( <FlatList data={items} keyExtractor={(item) => item.id.toString()} renderItem={({ item }) => <ItemCard item={item} />} /> ); } ``` ### Input Validation Use Zod schemas for type-safe validation: ```tsx import { z } from "zod"; const createItemSchema = z.object({ title: z.string().min(1, "Title required").max(255), description: z.string().max(1000).optional(), priority: z.enum(["low", "medium", "high"]).default("medium"), dueDate: z.date().optional(), }); // In router create: protectedProcedure .input(createItemSchema) .mutation(({ ctx, input }) => { // input is fully typed }), ``` --- ## LLM Integration Use the preconfigured LLM helpers. Credentials are injected from the platform (no manual setup required). ```ts import { invokeLLM } from "./server/_core/llm"; /** * Simple chat completion * type Role = "system" | "user" | "assistant" | "tool" | "function"; * type TextContent = { * type: "text"; * text: string; * }; * * type ImageContent = { * type: "image_url"; * image_url: { * url: string; * detail?: "auto" | "low" | "high"; * }; * }; * * type FileContent = { * type: "file_url"; * file_url: { * url: string; * mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ; * }; * }; * * export type Message = { * role: Role; * content: string | Array<ImageContent | TextContent | FileContent> * }; * * Supported parameters: * messages: Array<{ * role: 'system' | 'user' | 'assistant' | 'tool', * content: string | { tool_call: { name: string, arguments: string } } * }> * tool_choice?: 'none' | 'auto' | 'required' | { type: 'function', function: { name: string } } * tools?: Tool[] */ const response = await invokeLLM({ messages: [ { role: "system", content: "You are a helpful assistant." }, { role: "user", content: "Hello, world!" }, ], }); ``` Tips - Always call llm functions from server-side code (e.g., inside tRPC procedures), to avoid exposing your API key. - You don't need to manually set the model; the helper uses a sensible default. - LLM responses often contain markdown. Use `<Streamdown>{content}</Streamdown>` (imported from `streamdown`) to render markdown content with proper formatting and streaming support. - For image-based gen AI workflows, local `file://` and blob URLs don't work. Upload to S3 first, then pass the public URL to `invokeLLM()`. ### Structured Responses (JSON Schema) Ask the model to return structured JSON via `response_format`: ```ts import { invokeLLM } from "./server/_core/llm"; const structured = await invokeLLM({ messages: [ { role: "system", content: "You are a helpful assistant designed to output JSON." }, { role: "user", content: "Extract the name and age from the following text: \"My name is Alice and I am 30 years old.\"" }, ], response_format: { type: "json_schema", json_schema: { name: "person_info", strict: true, schema: { type: "object", properties: { name: { type: "string", description: "The name of the person" }, age: { type: "integer", description: "The age of the person" }, }, required: ["name", "age"], additionalProperties: false, }, }, }, }); // The model responds with JSON content matching the schema. // Access via `structured.choices[0].message.content` and JSON.parse if needed. ``` The helpers mirror the Python SDK semantics but produce JavaScript-first code, keeping credentials inside the server and ensuring every environment has access to the same token. **CRITICAL Note:** `json_schema` works for flat structures. For nested arrays/objects, use `json_object` instead. ```ts const response = await invokeLLM({ messages: [ { role: "system", content: `Analyze the food image. Return JSON: { "foods": [{ "name": "string", "calories": number }], "totalCalories": number }` }, { role: "user", content: [ { type: "text", text: "What food is this?" }, { type: "image_url", image_url: { url: imageUrl } } ] } ], response_format: { type: "json_object" } }); const data = JSON.parse(response.choices[0].message.content); ``` --- ## Voice Transcription Integration Use the preconfigured voice transcription helper that converts speech to text using Whisper API, no manual setup required. Example usage: ```ts import { transcribeAudio } from "./server/_core/voiceTranscription"; const result = await transcribeAudio({ audioUrl: "https://storage.example.com/audio/recording.mp3", language: "en", // Optional: helps improve accuracy prompt: "Transcribe meeting notes" // Optional: context hint }); // Returns native Whisper API response // result.text - Full transcription // result.language - Detected language (ISO-639-1) // result.segments - Timestamped segments with metadata ``` Tips - Accepts URL to pre-uploaded audio file - 16MB file size limit enforced during transcription, size flag to be set by frontend - Supported formats: webm, mp3, wav, ogg, m4a - Returns native Whisper API response with rich metadata - Frontend should handle audio capture, storage upload, and size validation --- ## Image Generation Integration Use the preconfigured image generation helper that connects to the internal ImageService, no manual setup required. Example usage: ```ts import { generateImage } from "./server/_core/imageGeneration.ts"; const { url: imageUrl } = await generateImage({ prompt: "A serene landscape with mountains" }); // For editing: const { url: imageUrl } = await generateImage({ prompt: "Add a rainbow to this landscape", originalImages: [{ url: "https://example.com/original.jpg", mimeType: "image/jpeg" }] }); ``` Tips - Always call from server-side code (e.g., inside tRPC procedures) to avoid exposing API keys - Image generation can take 5-20 seconds, implement proper loading states - Implement proper error handling as image generation can fail --- ## ☁️ File Storage Use the preconfigured S3 helpers in `server/storage.ts`. Credentials are injected from the platform (no manual setup required). ```ts import { storagePut } from "./server/storage"; // Upload bytes to S3 with non-enumerable path // The S3 bucket is public, so returned URLs work without additional signing process // Add random suffixes to file keys to prevent enumeration const fileKey = `${userId}-files/${fileName}-${randomSuffix()}.png` const { url } = await storagePut( fileKey, fileBuffer, // Buffer | Uint8Array | string "image/png" ); ``` Tips - Save metadata (path/URL/ACL/owner/mime/size) in your database; use S3 for the actual file bytes. This applies to all files including images, documents, and media. - For file uploads, have the client POST to your server, then call `storagePut` from your backend. --- ## ☁️ Data API When you need external data, use the omni_search with search_type = 'api' to see there's any built-in api available in Manus API Hub access. You only have to connect other api if there's no suitable built-in api available. --- ## Owner Notifications This template already ships with a `notifyOwner({ title, content })` helper (`server/_core/notification.ts`) and a protected tRPC mutation at `trpc.system.notifyOwner`. Use it whenever backend logic needs to push an operational update to the Manus project ownerβ€”common triggers are new form submissions, survey feedback, or workflow results. 1. On the server, call `await notifyOwner({ title, content })` or reuse the provided `system.notifyOwner` mutation from jobs/webhooks (`trpc.system.notifyOwner.useMutation()` on the client). 2. Handle the boolean return (`true` on success, `false` if the upstream service is temporarily unavailable) to decide whether you need a fallback channel. Keep this channel for owner-facing alerts; end-user messaging should flow through your app-specific systems. --- ## Environment Variables Available environment variables: | Variable | Description | |----------|-------------| | `DATABASE_URL` | MySQL/TiDB connection string | | `JWT_SECRET` | Session signing secret | | `VITE_APP_ID` | Manus OAuth app ID | | `OAUTH_SERVER_URL` | Manus OAuth backend URL | | `VITE_OAUTH_PORTAL_URL` | Manus login portal URL | | `OWNER_OPEN_ID` | Owner's Manus ID | | `OWNER_NAME` | Owner's display name | | `BUILT_IN_FORGE_API_URL` | Manus API endpoint | | `BUILT_IN_FORGE_API_KEY` | Manus API key | Expo runtime variables (prefixed with `EXPO_PUBLIC_`): | Variable | Description | |----------|-------------| | `EXPO_PUBLIC_APP_ID` | App ID for OAuth | | `EXPO_PUBLIC_API_BASE_URL` | API server URL | | `EXPO_PUBLIC_OAUTH_PORTAL_URL` | Login portal URL | --- ## Testing Write tests in `tests/` using Vitest: ```tsx // tests/items.test.ts import { describe, expect, it } from "vitest"; import { appRouter } from "../server/routers"; describe("items", () => { it("creates an item", async () => { const ctx = createMockContext({ userId: 1 }); const caller = appRouter.createCaller(ctx); const result = await caller.items.create({ title: "Test Item", description: "Test description", }); expect(result).toBeDefined(); }); }); ``` Run tests: ```bash pnpm test ``` --- ## Key Files Reference ## Core File References `drizzle/schema.ts` ```ts import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core"; /** * Core user table backing auth flow. * Extend this file with additional tables as your product grows. * Columns use camelCase to match both database fields and generated types. */ export const users = mysqlTable("users", { /** * Surrogate primary key. Auto-incremented numeric value managed by the database. * Use this for relations between tables. */ id: int("id").autoincrement().primaryKey(), /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */ openId: varchar("openId", { length: 64 }).notNull().unique(), name: text("name"), email: varchar("email", { length: 320 }), loginMethod: varchar("loginMethod", { length: 64 }), role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), }); export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; // TODO: Add your tables here ``` `server/db.ts` ```ts import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import { InsertUser, users } from "../drizzle/schema"; import { ENV } from "./_core/env"; let _db: ReturnType<typeof drizzle> | null = null; // Lazily create the drizzle instance so local tooling can run without a DB. export async function getDb() { if (!_db && process.env.DATABASE_URL) { try { _db = drizzle(process.env.DATABASE_URL); } catch (error) { console.warn("[Database] Failed to connect:", error); _db = null; } } return _db; } export async function upsertUser(user: InsertUser): Promise<void> { if (!user.openId) { throw new Error("User openId is required for upsert"); } const db = await getDb(); if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; } try { const values: InsertUser = { openId: user.openId, }; const updateSet: Record<string, unknown> = {}; const textFields = ["name", "email", "loginMethod"] as const; type TextField = (typeof textFields)[number]; const assignNullable = (field: TextField) => { const value = user[field]; if (value === undefined) return; const normalized = value ?? null; values[field] = normalized; updateSet[field] = normalized; }; textFields.forEach(assignNullable); if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; } if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; } else if (user.openId === ENV.ownerOpenId) { values.role = "admin"; updateSet.role = "admin"; } if (!values.lastSignedIn) { values.lastSignedIn = new Date(); } if (Object.keys(updateSet).length === 0) { updateSet.lastSignedIn = new Date(); } await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet, }); } catch (error) { console.error("[Database] Failed to upsert user:", error); throw error; } } export async function getUserByOpenId(openId: string) { const db = await getDb(); if (!db) { console.warn("[Database] Cannot get user: database not available"); return undefined; } const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1); return result.length > 0 ? result[0] : undefined; } // TODO: add feature queries here as your schema grows. ``` `server/routers.ts` ```ts import { COOKIE_NAME } from "../shared/const.js"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { publicProcedure, router } from "./_core/trpc"; export const appRouter = router({ // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly system: systemRouter, auth: router({ me: publicProcedure.query((opts) => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true, } as const; }), }), // TODO: add feature routers here, e.g. // todo: router({ // list: protectedProcedure.query(({ ctx }) => // db.getUserTodos(ctx.user.id) // ), // }), }); export type AppRouter = typeof appRouter; ``` `server/storage.ts` ```ts // Preconfigured storage helpers for Manus WebDev templates // Uses the Biz-provided storage proxy (Authorization: Bearer <token>) import { ENV } from "./_core/env"; type StorageConfig = { baseUrl: string; apiKey: string }; function getStorageConfig(): StorageConfig { const baseUrl = ENV.forgeApiUrl; const apiKey = ENV.forgeApiKey; if (!baseUrl || !apiKey) { throw new Error( "Storage proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY", ); } return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey }; } function buildUploadUrl(baseUrl: string, relKey: string): URL { const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl)); url.searchParams.set("path", normalizeKey(relKey)); return url; } async function buildDownloadUrl(baseUrl: string, relKey: string, apiKey: string): Promise<string> { const downloadApiUrl = new URL("v1/storage/downloadUrl", ensureTrailingSlash(baseUrl)); downloadApiUrl.searchParams.set("path", normalizeKey(relKey)); const response = await fetch(downloadApiUrl, { method: "GET", headers: buildAuthHeaders(apiKey), }); return (await response.json()).url; } function ensureTrailingSlash(value: string): string { return value.endsWith("/") ? value : `${value}/`; } function normalizeKey(relKey: string): string { return relKey.replace(/^\/+/, ""); } function toFormData( data: Buffer | Uint8Array | string, contentType: string, fileName: string, ): FormData { const blob = typeof data === "string" ? new Blob([data], { type: contentType }) : new Blob([data as any], { type: contentType }); const form = new FormData(); form.append("file", blob, fileName || "file"); return form; } function buildAuthHeaders(apiKey: string): HeadersInit { return { Authorization: `Bearer ${apiKey}` }; } export async function storagePut( relKey: string, data: Buffer | Uint8Array | string, contentType = "application/octet-stream", ): Promise<{ key: string; url: string }> { const { baseUrl, apiKey } = getStorageConfig(); const key = normalizeKey(relKey); const uploadUrl = buildUploadUrl(baseUrl, key); const formData = toFormData(data, contentType, key.split("/").pop() ?? key); const response = await fetch(uploadUrl, { method: "POST", headers: buildAuthHeaders(apiKey), body: formData, }); if (!response.ok) { const message = await response.text().catch(() => response.statusText); throw new Error( `Storage upload failed (${response.status} ${response.statusText}): ${message}`, ); } const url = (await response.json()).url; return { key, url }; } export async function storageGet(relKey: string): Promise<{ key: string; url: string }> { const { baseUrl, apiKey } = getStorageConfig(); const key = normalizeKey(relKey); return { key, url: await buildDownloadUrl(baseUrl, key, apiKey), }; } ``` `lib/trpc.ts` ```ts import { createTRPCReact } from "@trpc/react-query"; import { httpBatchLink } from "@trpc/client"; import superjson from "superjson"; import type { AppRouter } from "@/server/routers"; import { getApiBaseUrl } from "@/constants/oauth"; import * as Auth from "@/lib/_core/auth"; /** * tRPC React client for type-safe API calls. * * IMPORTANT (tRPC v11): The `transformer` must be inside `httpBatchLink`, * NOT at the root createClient level. This ensures client and server * use the same serialization format (superjson). */ export const trpc = createTRPCReact<AppRouter>(); /** * Creates the tRPC client with proper configuration. * Call this once in your app's root layout. */ export function createTRPCClient() { return trpc.createClient({ links: [ httpBatchLink({ url: `${getApiBaseUrl()}/api/trpc`, // tRPC v11: transformer MUST be inside httpBatchLink, not at root transformer: superjson, async headers() { const token = await Auth.getSessionToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }, // Custom fetch to include credentials for cookie-based auth fetch(url, options) { return fetch(url, { ...options, credentials: "include", }); }, }), ], }); } ``` `hooks/use-auth.ts` ```ts import * as Api from "@/lib/_core/api"; import * as Auth from "@/lib/_core/auth"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform } from "react-native"; type UseAuthOptions = { autoFetch?: boolean; }; export function useAuth(options?: UseAuthOptions) { const { autoFetch = true } = options ?? {}; const [user, setUser] = useState<Auth.User | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null); const fetchUser = useCallback(async () => { console.log("[useAuth] fetchUser called"); try { setLoading(true); setError(null); // Web platform: use cookie-based auth, fetch user from API if (Platform.OS === "web") { console.log("[useAuth] Web platform: fetching user from API..."); const apiUser = await Api.getMe(); console.log("[useAuth] API user response:", apiUser); if (apiUser) { const userInfo: Auth.User = { id: apiUser.id, openId: apiUser.openId, name: apiUser.name, email: apiUser.email, loginMethod: apiUser.loginMethod, lastSignedIn: new Date(apiUser.lastSignedIn), }; setUser(userInfo); // Cache user info in localStorage for faster subsequent loads await Auth.setUserInfo(userInfo); console.log("[useAuth] Web user set from API:", userInfo); } else { console.log("[useAuth] Web: No authenticated user from API"); setUser(null); await Auth.clearUserInfo(); } return; } // Native platform: use token-based auth console.log("[useAuth] Native platform: checking for session token..."); const sessionToken = await Auth.getSessionToken(); console.log( "[useAuth] Session token:", sessionToken ? `present (${sessionToken.substring(0, 20)}...)` : "missing", ); if (!sessionToken) { console.log("[useAuth] No session token, setting user to null"); setUser(null); return; } // Use cached user info for native (token validates the session) const cachedUser = await Auth.getUserInfo(); console.log("[useAuth] Cached user:", cachedUser); if (cachedUser) { console.log("[useAuth] Using cached user info"); setUser(cachedUser); } else { console.log("[useAuth] No cached user, setting user to null"); setUser(null); } } catch (err) { const error = err instanceof Error ? err : new Error("Failed to fetch user"); console.error("[useAuth] fetchUser error:", error); setError(error); setUser(null); } finally { setLoading(false); console.log("[useAuth] fetchUser completed, loading:", false); } }, []); const logout = useCallback(async () => { try { await Api.logout(); } catch (err) { console.error("[Auth] Logout API call failed:", err); // Continue with logout even if API call fails } finally { await Auth.removeSessionToken(); await Auth.clearUserInfo(); setUser(null); setError(null); } }, []); const isAuthenticated = useMemo(() => Boolean(user), [user]); useEffect(() => { console.log("[useAuth] useEffect triggered, autoFetch:", autoFetch, "platform:", Platform.OS); if (autoFetch) { if (Platform.OS === "web") { // Web: fetch user from API directly (user will login manually if needed) console.log("[useAuth] Web: fetching user from API..."); fetchUser(); } else { // Native: check for cached user info first for faster initial load Auth.getUserInfo().then((cachedUser) => { console.log("[useAuth] Native cached user check:", cachedUser); if (cachedUser) { console.log("[useAuth] Native: setting cached user immediately"); setUser(cachedUser); setLoading(false); } else { // No cached user, check session token fetchUser(); } }); } } else { console.log("[useAuth] autoFetch disabled, setting loading to false"); setLoading(false); } }, [autoFetch, fetchUser]); useEffect(() => { console.log("[useAuth] State updated:", { hasUser: !!user, loading, isAuthenticated, error: error?.message, }); }, [user, loading, isAuthenticated, error]); return { user, loading, error, isAuthenticated, refresh: fetchUser, logout, }; } ``` `tests/auth.logout.test.ts` ```ts import { describe, expect, it } from "vitest"; import { appRouter } from "../server/routers"; import { COOKIE_NAME } from "../shared/const"; import type { TrpcContext } from "../server/_core/context"; type CookieCall = { name: string; options: Record<string, unknown>; }; type AuthenticatedUser = NonNullable<TrpcContext["user"]>; function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] } { const clearedCookies: CookieCall[] = []; const user: AuthenticatedUser = { id: 1, openId: "sample-user", email: "sample@example.com", name: "Sample User", loginMethod: "manus", role: "user", createdAt: new Date(), updatedAt: new Date(), lastSignedIn: new Date(), }; const ctx: TrpcContext = { user, req: { protocol: "https", headers: {}, } as TrpcContext["req"], res: { clearCookie: (name: string, options: Record<string, unknown>) => { clearedCookies.push({ name, options }); }, } as TrpcContext["res"], }; return { ctx, clearedCookies }; } // TODO: Remove `.skip` below once you implement user authentication describe.skip("auth.logout", () => { it("clears the session cookie and reports success", async () => { const { ctx, clearedCookies } = createAuthContext(); const caller = appRouter.createCaller(ctx); const result = await caller.auth.logout(); expect(result).toEqual({ success: true }); expect(clearedCookies).toHaveLength(1); expect(clearedCookies[0]?.name).toBe(COOKIE_NAME); expect(clearedCookies[0]?.options).toMatchObject({ maxAge: -1, secure: true, sameSite: "none", httpOnly: true, path: "/", }); }); }); ``` --- ## Common Patterns ### Optimistic Updates Update UI immediately, revert on error: ```tsx const toggleComplete = trpc.items.update.useMutation({ onMutate: async (input) => { // Cancel outgoing queries await utils.items.list.cancel(); // Snapshot previous value const previous = utils.items.list.getData(); // Optimistically update utils.items.list.setData(undefined, (old) => old?.map((item) => item.id === input.id ? { ...item, completed: input.completed } : item ) ); return { previous }; }, onError: (err, input, context) => { // Revert on error utils.items.list.setData(undefined, context?.previous); }, onSettled: () => { // Refetch after mutation utils.items.list.invalidate(); }, }); ``` ### Pagination ```tsx // Router list: protectedProcedure .input(z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.number().optional(), })) .query(async ({ ctx, input }) => { const items = await db.getItems({ userId: ctx.user.id, limit: input.limit + 1, cursor: input.cursor, }); let nextCursor: number | undefined; if (items.length > input.limit) { const next = items.pop(); nextCursor = next?.id; } return { items, nextCursor }; }), // Frontend const { data, fetchNextPage, hasNextPage } = trpc.items.list.useInfiniteQuery( { limit: 20 }, { getNextPageParam: (lastPage) => lastPage.nextCursor } ); ``` --- ## Troubleshooting | Issue | Solution | |-------|----------| | "Database not available" | Check `DATABASE_URL` is set | | Auth not working | Verify OAuth callback URL matches | | tRPC type errors | Run `pnpm check` to verify types | | Mutations fail silently | Check browser console for errors | | Session expired | User needs to login again |
# Backend Development Guide
This guide covers server-side features including authentication, database, tRPC API, and integrations. **Only read this if your app needs these capabilities.**
---
## When Do You Need Backend?
| Scenario | Backend Needed? | User Auth Required? | Solution |
|----------|-----------------|---------------------|----------|
| Data stays on device only | No | No | Use `AsyncStorage` |
| Data syncs across devices | Yes | Yes | Database + tRPC |
| User accounts / login | Yes | Yes | Manus OAuth |
| AI-powered features | Yes | **Optional** | LLM Integration |
| User uploads files | Yes | **Optional** | S3 Storage |
| Server-side validation | Yes | **Optional** | tRPC procedures |
> **Note:** Backend β‰  User Auth. You can run a backend with LLM/Storage/ImageGen capabilities without requiring user login β€” just use `publicProcedure` instead of `protectedProcedure`. User auth is only mandatory when you need to identify users or sync user-specific data.
---
## File Structure
```
server/
db.ts ← Query helpers (add database functions here)
routers.ts ← tRPC procedures (add API routes here)
storage.ts ← S3 storage helpers (can extend)
_core/ ← Framework-level code (don't modify)
drizzle/
schema.ts ← Database tables & types (add your tables here)
relations.ts ← Table relationships
migrations/ ← Auto-generated migrations
shared/
types.ts ← Shared TypeScript types
const.ts ← Shared constants
_core/ ← Framework-level code (don't modify)
lib/
trpc.ts ← tRPC client (can customize headers)
_core/ ← Framework-level code (don't modify)
hooks/
use-auth.ts ← Auth state hook (don't modify)
tests/
*.test.ts ← Add your tests here
```
Only touch the files with "←" markers. Anything under `_core/` directories is framework-levelβ€”avoid editing unless you are extending the infrastructure.
---
## Authentication
### Overview
The template uses **Manus OAuth** for user authentication. It works differently on native and web:
| Platform | Auth Method | Token Storage |
|----------|-------------|---------------|
| iOS/Android | Bearer token | expo-secure-store |
| Web | HTTP-only cookie | Browser cookie |
### Using the Auth Hook
```tsx
import { useAuth } from "@/hooks/use-auth";
function MyScreen() {
const { user, isAuthenticated, loading, logout } = useAuth();
if (loading) return <ActivityIndicator />;
if (!isAuthenticated) {
return <LoginButton />;
}
return (
<View>
<ThemedText>Welcome, {user.name}</ThemedText>
<Button title="Logout" onPress={logout} />
</View>
);
}
```
### User Object
The `user` object contains:
```tsx
interface User {
id: number;
openId: string; // Manus OAuth ID
name: string | null;
email: string | null;
loginMethod: string;
role: "user" | "admin";
lastSignedIn: Date;
}
```