1236 lines
34 KiB
Markdown
1236 lines
34 KiB
Markdown
|
|
# 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 |
|