taqarub commited on
Commit
cd2a4c2
·
0 Parent(s):

Deploy Taqarub Node API server

Browse files
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
4
+
5
+ WORKDIR /app
6
+
7
+ COPY package.json pnpm-lock.yaml ./
8
+ RUN pnpm install --frozen-lockfile
9
+
10
+ COPY server/ server/
11
+ COPY shared/ shared/
12
+ COPY drizzle/ drizzle/
13
+ COPY drizzle.config.ts tsconfig.json ./
14
+
15
+ RUN npx esbuild server/_core/index.ts \
16
+ --platform=node \
17
+ --packages=external \
18
+ --bundle \
19
+ --format=cjs \
20
+ --outdir=dist
21
+
22
+ RUN pnpm prune --prod
23
+
24
+ ENV NODE_ENV=production
25
+ ENV PORT=7860
26
+
27
+ EXPOSE 7860
28
+
29
+ CMD ["node", "dist/index.js"]
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Taqarub Node API
3
+ emoji: 🚀
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Taqarub Node API Server
drizzle.config.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ const connectionString = process.env.DATABASE_URL;
4
+ if (!connectionString) {
5
+ throw new Error("DATABASE_URL is required to run drizzle commands");
6
+ }
7
+
8
+ export default defineConfig({
9
+ schema: "./drizzle/schema.ts",
10
+ out: "./drizzle",
11
+ dialect: "mysql",
12
+ dbCredentials: {
13
+ url: connectionString,
14
+ },
15
+ });
drizzle/0000_elite_eternals.sql ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE `users` (
2
+ `id` int AUTO_INCREMENT NOT NULL,
3
+ `openId` varchar(64) NOT NULL,
4
+ `name` text,
5
+ `email` varchar(320),
6
+ `loginMethod` varchar(64),
7
+ `role` enum('user','admin') NOT NULL DEFAULT 'user',
8
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
9
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
10
+ `lastSignedIn` timestamp NOT NULL DEFAULT (now()),
11
+ CONSTRAINT `users_id` PRIMARY KEY(`id`),
12
+ CONSTRAINT `users_openId_unique` UNIQUE(`openId`)
13
+ );
drizzle/meta/0000_snapshot.json ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "5",
3
+ "dialect": "mysql",
4
+ "id": "3c3a03ea-b871-416a-b531-aa772cca8b00",
5
+ "prevId": "00000000-0000-0000-0000-000000000000",
6
+ "tables": {
7
+ "users": {
8
+ "name": "users",
9
+ "columns": {
10
+ "id": {
11
+ "name": "id",
12
+ "type": "int",
13
+ "primaryKey": false,
14
+ "notNull": true,
15
+ "autoincrement": true
16
+ },
17
+ "openId": {
18
+ "name": "openId",
19
+ "type": "varchar(64)",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "name": {
25
+ "name": "name",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": false,
29
+ "autoincrement": false
30
+ },
31
+ "email": {
32
+ "name": "email",
33
+ "type": "varchar(320)",
34
+ "primaryKey": false,
35
+ "notNull": false,
36
+ "autoincrement": false
37
+ },
38
+ "loginMethod": {
39
+ "name": "loginMethod",
40
+ "type": "varchar(64)",
41
+ "primaryKey": false,
42
+ "notNull": false,
43
+ "autoincrement": false
44
+ },
45
+ "role": {
46
+ "name": "role",
47
+ "type": "enum('user','admin')",
48
+ "primaryKey": false,
49
+ "notNull": true,
50
+ "autoincrement": false,
51
+ "default": "'user'"
52
+ },
53
+ "createdAt": {
54
+ "name": "createdAt",
55
+ "type": "timestamp",
56
+ "primaryKey": false,
57
+ "notNull": true,
58
+ "autoincrement": false,
59
+ "default": "(now())"
60
+ },
61
+ "updatedAt": {
62
+ "name": "updatedAt",
63
+ "type": "timestamp",
64
+ "primaryKey": false,
65
+ "notNull": true,
66
+ "autoincrement": false,
67
+ "onUpdate": true,
68
+ "default": "(now())"
69
+ },
70
+ "lastSignedIn": {
71
+ "name": "lastSignedIn",
72
+ "type": "timestamp",
73
+ "primaryKey": false,
74
+ "notNull": true,
75
+ "autoincrement": false,
76
+ "default": "(now())"
77
+ }
78
+ },
79
+ "indexes": {},
80
+ "foreignKeys": {},
81
+ "compositePrimaryKeys": {
82
+ "users_id": {
83
+ "name": "users_id",
84
+ "columns": ["id"]
85
+ }
86
+ },
87
+ "uniqueConstraints": {
88
+ "users_openId_unique": {
89
+ "name": "users_openId_unique",
90
+ "columns": ["openId"]
91
+ }
92
+ },
93
+ "checkConstraint": {}
94
+ }
95
+ },
96
+ "views": {},
97
+ "_meta": {
98
+ "schemas": {},
99
+ "tables": {},
100
+ "columns": {}
101
+ },
102
+ "internal": {
103
+ "tables": {},
104
+ "indexes": {}
105
+ }
106
+ }
drizzle/meta/_journal.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "7",
3
+ "dialect": "mysql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "5",
8
+ "when": 1763372440610,
9
+ "tag": "0000_elite_eternals",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
drizzle/migrations/.gitkeep ADDED
File without changes
drizzle/relations.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ import {} from "./schema";
drizzle/schema.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
2
+
3
+ /**
4
+ * Core user table backing auth flow.
5
+ * Extend this file with additional tables as your product grows.
6
+ * Columns use camelCase to match both database fields and generated types.
7
+ */
8
+ export const users = mysqlTable("users", {
9
+ /**
10
+ * Surrogate primary key. Auto-incremented numeric value managed by the database.
11
+ * Use this for relations between tables.
12
+ */
13
+ id: int("id").autoincrement().primaryKey(),
14
+ /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
15
+ openId: varchar("openId", { length: 64 }).notNull().unique(),
16
+ name: text("name"),
17
+ email: varchar("email", { length: 320 }),
18
+ loginMethod: varchar("loginMethod", { length: 64 }),
19
+ role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
20
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
21
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
22
+ lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
23
+ });
24
+
25
+ export type User = typeof users.$inferSelect;
26
+ export type InsertUser = typeof users.$inferInsert;
27
+
28
+ // TODO: Add your tables here
package.json ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "app-template",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "main": "expo-router/entry",
6
+ "scripts": {
7
+ "dev": "concurrently -k \"pnpm dev:server\" \"pnpm dev:metro\"",
8
+ "dev:server": "cross-env NODE_ENV=development tsx watch server/_core/index.ts",
9
+ "dev:metro": "cross-env EXPO_USE_METRO_WORKSPACE_ROOT=1 npx expo start --web --port 8081",
10
+ "build": "esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
11
+ "start": "cross-env NODE_ENV=production node dist/index.js",
12
+ "check": "tsc --noEmit",
13
+ "lint": "expo lint",
14
+ "format": "prettier --write .",
15
+ "test": "vitest run",
16
+ "db:push": "drizzle-kit generate && drizzle-kit migrate",
17
+ "android": "expo run:android",
18
+ "ios": "expo run:ios",
19
+ "qr": "node scripts/generate_qr.mjs"
20
+ },
21
+ "dependencies": {
22
+ "@expo/vector-icons": "^15.0.3",
23
+ "@react-native-async-storage/async-storage": "^2.2.0",
24
+ "@react-navigation/bottom-tabs": "^7.8.12",
25
+ "@react-navigation/elements": "^2.9.2",
26
+ "@react-navigation/native": "^7.1.25",
27
+ "@tanstack/react-query": "^5.90.12",
28
+ "@trpc/client": "11.7.2",
29
+ "@trpc/react-query": "11.7.2",
30
+ "@trpc/server": "11.7.2",
31
+ "@types/three": "^0.183.1",
32
+ "@types/ws": "^8.18.1",
33
+ "axios": "^1.13.2",
34
+ "clsx": "^2.1.1",
35
+ "cookie": "^1.1.1",
36
+ "dotenv": "^16.6.1",
37
+ "drizzle-orm": "^0.44.7",
38
+ "expo": "~54.0.29",
39
+ "expo-asset": "~12.0.12",
40
+ "expo-audio": "~1.1.0",
41
+ "expo-build-properties": "^1.0.10",
42
+ "expo-camera": "~17.0.10",
43
+ "expo-clipboard": "~8.0.8",
44
+ "expo-constants": "~18.0.12",
45
+ "expo-dev-client": "~6.0.20",
46
+ "expo-file-system": "~19.0.21",
47
+ "expo-font": "~14.0.10",
48
+ "expo-gl": "~16.0.10",
49
+ "expo-haptics": "~15.0.8",
50
+ "expo-image": "~3.0.11",
51
+ "expo-keep-awake": "~15.0.8",
52
+ "expo-linking": "~8.0.10",
53
+ "expo-modules-core": "~3.0.29",
54
+ "expo-notifications": "~0.32.15",
55
+ "expo-router": "~6.0.19",
56
+ "expo-secure-store": "~15.0.8",
57
+ "expo-speech": "~14.0.8",
58
+ "expo-speech-recognition": "^3.1.2",
59
+ "expo-splash-screen": "~31.0.12",
60
+ "expo-status-bar": "~3.0.9",
61
+ "expo-symbols": "~1.0.8",
62
+ "expo-system-ui": "~6.0.9",
63
+ "expo-video": "~3.0.15",
64
+ "expo-web-browser": "~15.0.10",
65
+ "express": "^4.22.1",
66
+ "jose": "6.1.0",
67
+ "mediapipe": "^0.0.1-security",
68
+ "mysql2": "^3.16.0",
69
+ "nativewind": "^4.2.1",
70
+ "react": "19.1.0",
71
+ "react-dom": "19.1.0",
72
+ "react-native": "0.81.5",
73
+ "react-native-gesture-handler": "~2.28.0",
74
+ "react-native-reanimated": "~4.1.6",
75
+ "react-native-safe-area-context": "~5.6.2",
76
+ "react-native-screens": "~4.16.0",
77
+ "react-native-svg": "15.12.1",
78
+ "react-native-web": "~0.21.2",
79
+ "react-native-worklets": "0.5.1",
80
+ "superjson": "^1.13.3",
81
+ "tailwind-merge": "^2.6.0",
82
+ "three": "^0.183.2",
83
+ "ws": "^8.19.0",
84
+ "zod": "^4.2.1"
85
+ },
86
+ "devDependencies": {
87
+ "@expo/ngrok": "^4.1.3",
88
+ "@types/cookie": "^0.6.0",
89
+ "@types/express": "^4.17.25",
90
+ "@types/node": "^22.19.3",
91
+ "@types/qrcode": "^1.5.6",
92
+ "@types/react": "~19.1.17",
93
+ "concurrently": "^9.2.1",
94
+ "cross-env": "^7.0.3",
95
+ "drizzle-kit": "^0.31.8",
96
+ "esbuild": "^0.25.12",
97
+ "eslint": "^9.39.2",
98
+ "eslint-config-expo": "~10.0.0",
99
+ "prettier": "^3.7.4",
100
+ "qrcode": "^1.5.4",
101
+ "tailwindcss": "^3.4.17",
102
+ "tsx": "^4.21.0",
103
+ "typescript": "~5.9.3",
104
+ "vitest": "^2.1.9",
105
+ "yaml": "^2.8.2"
106
+ },
107
+ "packageManager": "pnpm@9.12.0"
108
+ }
pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
server/README.md ADDED
@@ -0,0 +1,1235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Development Guide
2
+
3
+ This guide covers server-side features including authentication, database, tRPC API, and integrations. **Only read this if your app needs these capabilities.**
4
+
5
+ ---
6
+
7
+ ## When Do You Need Backend?
8
+
9
+ | Scenario | Backend Needed? | User Auth Required? | Solution |
10
+ |----------|-----------------|---------------------|----------|
11
+ | Data stays on device only | No | No | Use `AsyncStorage` |
12
+ | Data syncs across devices | Yes | Yes | Database + tRPC |
13
+ | User accounts / login | Yes | Yes | Manus OAuth |
14
+ | AI-powered features | Yes | **Optional** | LLM Integration |
15
+ | User uploads files | Yes | **Optional** | S3 Storage |
16
+ | Server-side validation | Yes | **Optional** | tRPC procedures |
17
+
18
+ > **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.
19
+
20
+ ---
21
+
22
+ ## File Structure
23
+
24
+ ```
25
+ server/
26
+ db.ts ← Query helpers (add database functions here)
27
+ routers.ts ← tRPC procedures (add API routes here)
28
+ storage.ts ← S3 storage helpers (can extend)
29
+ _core/ ← Framework-level code (don't modify)
30
+ drizzle/
31
+ schema.ts ← Database tables & types (add your tables here)
32
+ relations.ts ← Table relationships
33
+ migrations/ ← Auto-generated migrations
34
+ shared/
35
+ types.ts ← Shared TypeScript types
36
+ const.ts ← Shared constants
37
+ _core/ ← Framework-level code (don't modify)
38
+ lib/
39
+ trpc.ts ← tRPC client (can customize headers)
40
+ _core/ ← Framework-level code (don't modify)
41
+ hooks/
42
+ use-auth.ts ← Auth state hook (don't modify)
43
+ tests/
44
+ *.test.ts ← Add your tests here
45
+ ```
46
+
47
+ Only touch the files with "←" markers. Anything under `_core/` directories is framework-level—avoid editing unless you are extending the infrastructure.
48
+
49
+ ---
50
+
51
+ ## Authentication
52
+
53
+ ### Overview
54
+
55
+ The template uses **Manus OAuth** for user authentication. It works differently on native and web:
56
+
57
+ | Platform | Auth Method | Token Storage |
58
+ |----------|-------------|---------------|
59
+ | iOS/Android | Bearer token | expo-secure-store |
60
+ | Web | HTTP-only cookie | Browser cookie |
61
+
62
+ ### Using the Auth Hook
63
+
64
+ ```tsx
65
+ import { useAuth } from "@/hooks/use-auth";
66
+
67
+ function MyScreen() {
68
+ const { user, isAuthenticated, loading, logout } = useAuth();
69
+
70
+ if (loading) return <ActivityIndicator />;
71
+
72
+ if (!isAuthenticated) {
73
+ return <LoginButton />;
74
+ }
75
+
76
+ return (
77
+ <View>
78
+ <ThemedText>Welcome, {user.name}</ThemedText>
79
+ <Button title="Logout" onPress={logout} />
80
+ </View>
81
+ );
82
+ }
83
+ ```
84
+
85
+ ### User Object
86
+
87
+ The `user` object contains:
88
+
89
+ ```tsx
90
+ interface User {
91
+ id: number;
92
+ openId: string; // Manus OAuth ID
93
+ name: string | null;
94
+ email: string | null;
95
+ loginMethod: string;
96
+ role: "user" | "admin";
97
+ lastSignedIn: Date;
98
+ }
99
+ ```
100
+
101
+ ### Login Flow (Native)
102
+
103
+ 1. User taps Login button
104
+ 2. `startOAuthLogin()` calls `Linking.openURL()` which opens Manus OAuth in the system browser
105
+ 3. User authenticates
106
+ 4. OAuth redirects to the app's deep link (`/oauth/callback`) with code/state params
107
+ 5. App opens the callback handler
108
+ 6. Callback exchanges code for session token
109
+ 7. Token stored in SecureStore
110
+ 8. User redirected to home
111
+
112
+ ### Login Flow (Web)
113
+
114
+ 1. User clicks Login button
115
+ 2. Browser redirects to Manus OAuth
116
+ 3. User authenticates
117
+ 4. Redirect back with session cookie
118
+ 5. Cookie automatically sent with requests
119
+
120
+ ### Protected Routes
121
+
122
+ Use `protectedProcedure` in tRPC to require authentication:
123
+
124
+ ```tsx
125
+ // server/routers.ts
126
+ import { protectedProcedure } from "./_core/trpc";
127
+
128
+ export const appRouter = router({
129
+ myFeature: router({
130
+ getData: protectedProcedure.query(({ ctx }) => {
131
+ // ctx.user is guaranteed to exist
132
+ return db.getUserData(ctx.user.id);
133
+ }),
134
+ }),
135
+ });
136
+
137
+ ```
138
+ ### Frontend: Handling Auth Errors
139
+ protectedProcedure MUST HANDLE UNAUTHORIZED when user is not logged in. Always handle this in the frontend:
140
+ ```tsx
141
+ try {
142
+ await trpc.someProtectedEndpoint.mutate(data);
143
+ } catch (error) {
144
+ if (error.data?.code === 'UNAUTHORIZED') {
145
+ router.push('/login');
146
+ return;
147
+ }
148
+ throw error;
149
+ }
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Database
155
+
156
+ ### Schema Definition
157
+
158
+ Define your tables in `drizzle/schema.ts`:
159
+
160
+ ```tsx
161
+ import { int, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
162
+
163
+ // Users table (already exists)
164
+ export const users = mysqlTable("users", {
165
+ id: int("id").autoincrement().primaryKey(),
166
+ openId: varchar("openId", { length: 64 }).notNull().unique(),
167
+ name: text("name"),
168
+ email: varchar("email", { length: 320 }),
169
+ role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
170
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
171
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
172
+ });
173
+
174
+ // Add your tables
175
+ export const items = mysqlTable("items", {
176
+ id: int("id").autoincrement().primaryKey(),
177
+ userId: int("userId").notNull(),
178
+ title: varchar("title", { length: 255 }).notNull(),
179
+ description: text("description"),
180
+ completed: boolean("completed").default(false).notNull(),
181
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
182
+ });
183
+
184
+ // Export types
185
+ export type User = typeof users.$inferSelect;
186
+ export type Item = typeof items.$inferSelect;
187
+ export type InsertItem = typeof items.$inferInsert;
188
+ ```
189
+
190
+ ### Running Migrations
191
+
192
+ After editing the schema, push changes to the database:
193
+
194
+ ```bash
195
+ pnpm db:push
196
+ ```
197
+
198
+ This runs `drizzle-kit generate` and `drizzle-kit migrate`.
199
+
200
+ ### Query Helpers
201
+
202
+ Add database queries in `server/db.ts`:
203
+
204
+ ```tsx
205
+ import { eq } from "drizzle-orm";
206
+ import { getDb } from "./_core/db";
207
+ import { items, InsertItem } from "../drizzle/schema";
208
+
209
+ export async function getUserItems(userId: number) {
210
+ const db = await getDb();
211
+ if (!db) return [];
212
+
213
+ return db.select().from(items).where(eq(items.userId, userId));
214
+ }
215
+
216
+ export async function createItem(data: InsertItem) {
217
+ const db = await getDb();
218
+ if (!db) throw new Error("Database not available");
219
+
220
+ const result = await db.insert(items).values(data);
221
+ return result.insertId;
222
+ }
223
+
224
+ export async function updateItem(id: number, data: Partial<InsertItem>) {
225
+ const db = await getDb();
226
+ if (!db) throw new Error("Database not available");
227
+
228
+ await db.update(items).set(data).where(eq(items.id, id));
229
+ }
230
+
231
+ export async function deleteItem(id: number) {
232
+ const db = await getDb();
233
+ if (!db) throw new Error("Database not available");
234
+
235
+ await db.delete(items).where(eq(items.id, id));
236
+ }
237
+ ```
238
+
239
+ ---
240
+
241
+ ## tRPC API
242
+
243
+ ### Adding Routes
244
+
245
+ Define API routes in `server/routers.ts`:
246
+
247
+ ```tsx
248
+ import { z } from "zod";
249
+ import { router, protectedProcedure, publicProcedure } from "./_core/trpc";
250
+ import * as db from "./db";
251
+
252
+ export const appRouter = router({
253
+ // Public route (no auth required)
254
+ health: publicProcedure.query(() => ({ status: "ok" })),
255
+
256
+ // Protected routes (auth required)
257
+ items: router({
258
+ list: protectedProcedure.query(({ ctx }) => {
259
+ return db.getUserItems(ctx.user.id);
260
+ }),
261
+
262
+ create: protectedProcedure
263
+ .input(z.object({
264
+ title: z.string().min(1).max(255),
265
+ description: z.string().optional(),
266
+ }))
267
+ .mutation(({ ctx, input }) => {
268
+ return db.createItem({
269
+ userId: ctx.user.id,
270
+ title: input.title,
271
+ description: input.description,
272
+ });
273
+ }),
274
+
275
+ update: protectedProcedure
276
+ .input(z.object({
277
+ id: z.number(),
278
+ title: z.string().min(1).max(255).optional(),
279
+ completed: z.boolean().optional(),
280
+ }))
281
+ .mutation(({ input }) => {
282
+ return db.updateItem(input.id, input);
283
+ }),
284
+
285
+ delete: protectedProcedure
286
+ .input(z.object({ id: z.number() }))
287
+ .mutation(({ input }) => {
288
+ return db.deleteItem(input.id);
289
+ }),
290
+ }),
291
+ });
292
+
293
+ export type AppRouter = typeof appRouter;
294
+ ```
295
+
296
+ ### Calling from Frontend
297
+
298
+ Use tRPC hooks in your components:
299
+
300
+ ```tsx
301
+ import { trpc } from "@/lib/trpc";
302
+
303
+ function ItemList() {
304
+ // Query
305
+ const { data: items, isLoading, refetch } = trpc.items.list.useQuery();
306
+
307
+ // Mutation
308
+ const createMutation = trpc.items.create.useMutation({
309
+ onSuccess: () => refetch(),
310
+ });
311
+
312
+ const handleCreate = async () => {
313
+ await createMutation.mutateAsync({
314
+ title: "New Item",
315
+ description: "Description here",
316
+ });
317
+ };
318
+
319
+ if (isLoading) return <ActivityIndicator />;
320
+
321
+ return (
322
+ <FlatList
323
+ data={items}
324
+ keyExtractor={(item) => item.id.toString()}
325
+ renderItem={({ item }) => <ItemCard item={item} />}
326
+ />
327
+ );
328
+ }
329
+ ```
330
+
331
+ ### Input Validation
332
+
333
+ Use Zod schemas for type-safe validation:
334
+
335
+ ```tsx
336
+ import { z } from "zod";
337
+
338
+ const createItemSchema = z.object({
339
+ title: z.string().min(1, "Title required").max(255),
340
+ description: z.string().max(1000).optional(),
341
+ priority: z.enum(["low", "medium", "high"]).default("medium"),
342
+ dueDate: z.date().optional(),
343
+ });
344
+
345
+ // In router
346
+ create: protectedProcedure
347
+ .input(createItemSchema)
348
+ .mutation(({ ctx, input }) => {
349
+ // input is fully typed
350
+ }),
351
+ ```
352
+
353
+ ---
354
+
355
+ ## LLM Integration
356
+
357
+ Use the preconfigured LLM helpers. Credentials are injected from the platform (no manual setup required).
358
+
359
+ ```ts
360
+ import { invokeLLM } from "./server/_core/llm";
361
+
362
+ /**
363
+ * Simple chat completion
364
+ * type Role = "system" | "user" | "assistant" | "tool" | "function";
365
+ * type TextContent = {
366
+ * type: "text";
367
+ * text: string;
368
+ * };
369
+ *
370
+ * type ImageContent = {
371
+ * type: "image_url";
372
+ * image_url: {
373
+ * url: string;
374
+ * detail?: "auto" | "low" | "high";
375
+ * };
376
+ * };
377
+ *
378
+ * type FileContent = {
379
+ * type: "file_url";
380
+ * file_url: {
381
+ * url: string;
382
+ * mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ;
383
+ * };
384
+ * };
385
+ *
386
+ * export type Message = {
387
+ * role: Role;
388
+ * content: string | Array<ImageContent | TextContent | FileContent>
389
+ * };
390
+ *
391
+ * Supported parameters:
392
+ * messages: Array<{
393
+ * role: 'system' | 'user' | 'assistant' | 'tool',
394
+ * content: string | { tool_call: { name: string, arguments: string } }
395
+ * }>
396
+ * tool_choice?: 'none' | 'auto' | 'required' | { type: 'function', function: { name: string } }
397
+ * tools?: Tool[]
398
+ */
399
+ const response = await invokeLLM({
400
+ messages: [
401
+ { role: "system", content: "You are a helpful assistant." },
402
+ { role: "user", content: "Hello, world!" },
403
+ ],
404
+ });
405
+ ```
406
+
407
+ Tips
408
+ - Always call llm functions from server-side code (e.g., inside tRPC procedures), to avoid exposing your API key.
409
+ - You don't need to manually set the model; the helper uses a sensible default.
410
+ - LLM responses often contain markdown. Use `<Streamdown>{content}</Streamdown>` (imported from `streamdown`) to render markdown content with proper formatting and streaming support.
411
+ - 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()`.
412
+
413
+ ### Structured Responses (JSON Schema)
414
+
415
+ Ask the model to return structured JSON via `response_format`:
416
+
417
+ ```ts
418
+ import { invokeLLM } from "./server/_core/llm";
419
+
420
+ const structured = await invokeLLM({
421
+ messages: [
422
+ { role: "system", content: "You are a helpful assistant designed to output JSON." },
423
+ { role: "user", content: "Extract the name and age from the following text: \"My name is Alice and I am 30 years old.\"" },
424
+ ],
425
+ response_format: {
426
+ type: "json_schema",
427
+ json_schema: {
428
+ name: "person_info",
429
+ strict: true,
430
+ schema: {
431
+ type: "object",
432
+ properties: {
433
+ name: { type: "string", description: "The name of the person" },
434
+ age: { type: "integer", description: "The age of the person" },
435
+ },
436
+ required: ["name", "age"],
437
+ additionalProperties: false,
438
+ },
439
+ },
440
+ },
441
+ });
442
+
443
+ // The model responds with JSON content matching the schema.
444
+ // Access via `structured.choices[0].message.content` and JSON.parse if needed.
445
+ ```
446
+ 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.
447
+
448
+ **CRITICAL Note:** `json_schema` works for flat structures. For nested arrays/objects, use `json_object` instead.
449
+ ```ts
450
+ const response = await invokeLLM({
451
+ messages: [
452
+ {
453
+ role: "system",
454
+ content: `Analyze the food image. Return JSON:
455
+ {
456
+ "foods": [{ "name": "string", "calories": number }],
457
+ "totalCalories": number
458
+ }`
459
+ },
460
+ {
461
+ role: "user",
462
+ content: [
463
+ { type: "text", text: "What food is this?" },
464
+ { type: "image_url", image_url: { url: imageUrl } }
465
+ ]
466
+ }
467
+ ],
468
+ response_format: { type: "json_object" }
469
+ });
470
+ const data = JSON.parse(response.choices[0].message.content);
471
+ ```
472
+
473
+ ---
474
+
475
+ ## Voice Transcription Integration
476
+
477
+ Use the preconfigured voice transcription helper that converts speech to text using Whisper API, no manual setup required.
478
+
479
+ Example usage:
480
+ ```ts
481
+ import { transcribeAudio } from "./server/_core/voiceTranscription";
482
+
483
+ const result = await transcribeAudio({
484
+ audioUrl: "https://storage.example.com/audio/recording.mp3",
485
+ language: "en", // Optional: helps improve accuracy
486
+ prompt: "Transcribe meeting notes" // Optional: context hint
487
+ });
488
+
489
+ // Returns native Whisper API response
490
+ // result.text - Full transcription
491
+ // result.language - Detected language (ISO-639-1)
492
+ // result.segments - Timestamped segments with metadata
493
+ ```
494
+
495
+ Tips
496
+ - Accepts URL to pre-uploaded audio file
497
+ - 16MB file size limit enforced during transcription, size flag to be set by frontend
498
+ - Supported formats: webm, mp3, wav, ogg, m4a
499
+ - Returns native Whisper API response with rich metadata
500
+ - Frontend should handle audio capture, storage upload, and size validation
501
+
502
+ ---
503
+
504
+ ## Image Generation Integration
505
+
506
+ Use the preconfigured image generation helper that connects to the internal ImageService, no manual setup required.
507
+
508
+ Example usage:
509
+ ```ts
510
+ import { generateImage } from "./server/_core/imageGeneration.ts";
511
+
512
+ const { url: imageUrl } = await generateImage({
513
+ prompt: "A serene landscape with mountains"
514
+ });
515
+ // For editing:
516
+ const { url: imageUrl } = await generateImage({
517
+ prompt: "Add a rainbow to this landscape",
518
+ originalImages: [{
519
+ url: "https://example.com/original.jpg",
520
+ mimeType: "image/jpeg"
521
+ }]
522
+ });
523
+ ```
524
+
525
+ Tips
526
+ - Always call from server-side code (e.g., inside tRPC procedures) to avoid exposing API keys
527
+ - Image generation can take 5-20 seconds, implement proper loading states
528
+ - Implement proper error handling as image generation can fail
529
+
530
+ ---
531
+
532
+ ## ☁️ File Storage
533
+
534
+ Use the preconfigured S3 helpers in `server/storage.ts`. Credentials are injected from the platform (no manual setup required).
535
+
536
+ ```ts
537
+ import { storagePut } from "./server/storage";
538
+
539
+ // Upload bytes to S3 with non-enumerable path
540
+ // The S3 bucket is public, so returned URLs work without additional signing process
541
+ // Add random suffixes to file keys to prevent enumeration
542
+ const fileKey = `${userId}-files/${fileName}-${randomSuffix()}.png`
543
+ const { url } = await storagePut(
544
+ fileKey,
545
+ fileBuffer, // Buffer | Uint8Array | string
546
+ "image/png"
547
+ );
548
+ ```
549
+
550
+ Tips
551
+ - 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.
552
+ - For file uploads, have the client POST to your server, then call `storagePut` from your backend.
553
+
554
+ ---
555
+
556
+ ## ☁️ Data API
557
+
558
+ 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.
559
+
560
+ ---
561
+
562
+ ## Owner Notifications
563
+
564
+ 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.
565
+
566
+ 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).
567
+ 2. Handle the boolean return (`true` on success, `false` if the upstream service is temporarily unavailable) to decide whether you need a fallback channel.
568
+
569
+ Keep this channel for owner-facing alerts; end-user messaging should flow through your app-specific systems.
570
+
571
+ ---
572
+
573
+ ## Environment Variables
574
+
575
+ Available environment variables:
576
+
577
+ | Variable | Description |
578
+ |----------|-------------|
579
+ | `DATABASE_URL` | MySQL/TiDB connection string |
580
+ | `JWT_SECRET` | Session signing secret |
581
+ | `VITE_APP_ID` | Manus OAuth app ID |
582
+ | `OAUTH_SERVER_URL` | Manus OAuth backend URL |
583
+ | `VITE_OAUTH_PORTAL_URL` | Manus login portal URL |
584
+ | `OWNER_OPEN_ID` | Owner's Manus ID |
585
+ | `OWNER_NAME` | Owner's display name |
586
+ | `BUILT_IN_FORGE_API_URL` | Manus API endpoint |
587
+ | `BUILT_IN_FORGE_API_KEY` | Manus API key |
588
+
589
+ Expo runtime variables (prefixed with `EXPO_PUBLIC_`):
590
+
591
+ | Variable | Description |
592
+ |----------|-------------|
593
+ | `EXPO_PUBLIC_APP_ID` | App ID for OAuth |
594
+ | `EXPO_PUBLIC_API_BASE_URL` | API server URL |
595
+ | `EXPO_PUBLIC_OAUTH_PORTAL_URL` | Login portal URL |
596
+
597
+ ---
598
+
599
+ ## Testing
600
+
601
+ Write tests in `tests/` using Vitest:
602
+
603
+ ```tsx
604
+ // tests/items.test.ts
605
+ import { describe, expect, it } from "vitest";
606
+ import { appRouter } from "../server/routers";
607
+
608
+ describe("items", () => {
609
+ it("creates an item", async () => {
610
+ const ctx = createMockContext({ userId: 1 });
611
+ const caller = appRouter.createCaller(ctx);
612
+
613
+ const result = await caller.items.create({
614
+ title: "Test Item",
615
+ description: "Test description",
616
+ });
617
+
618
+ expect(result).toBeDefined();
619
+ });
620
+ });
621
+ ```
622
+
623
+ Run tests:
624
+
625
+ ```bash
626
+ pnpm test
627
+ ```
628
+
629
+ ---
630
+
631
+ ## Key Files Reference
632
+
633
+ ## Core File References
634
+
635
+ `drizzle/schema.ts`
636
+ ```ts
637
+ import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
638
+
639
+ /**
640
+ * Core user table backing auth flow.
641
+ * Extend this file with additional tables as your product grows.
642
+ * Columns use camelCase to match both database fields and generated types.
643
+ */
644
+ export const users = mysqlTable("users", {
645
+ /**
646
+ * Surrogate primary key. Auto-incremented numeric value managed by the database.
647
+ * Use this for relations between tables.
648
+ */
649
+ id: int("id").autoincrement().primaryKey(),
650
+ /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
651
+ openId: varchar("openId", { length: 64 }).notNull().unique(),
652
+ name: text("name"),
653
+ email: varchar("email", { length: 320 }),
654
+ loginMethod: varchar("loginMethod", { length: 64 }),
655
+ role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
656
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
657
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
658
+ lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
659
+ });
660
+
661
+ export type User = typeof users.$inferSelect;
662
+ export type InsertUser = typeof users.$inferInsert;
663
+
664
+ // TODO: Add your tables here
665
+ ```
666
+
667
+ `server/db.ts`
668
+ ```ts
669
+ import { eq } from "drizzle-orm";
670
+ import { drizzle } from "drizzle-orm/mysql2";
671
+ import { InsertUser, users } from "../drizzle/schema";
672
+ import { ENV } from "./_core/env";
673
+
674
+ let _db: ReturnType<typeof drizzle> | null = null;
675
+
676
+ // Lazily create the drizzle instance so local tooling can run without a DB.
677
+ export async function getDb() {
678
+ if (!_db && process.env.DATABASE_URL) {
679
+ try {
680
+ _db = drizzle(process.env.DATABASE_URL);
681
+ } catch (error) {
682
+ console.warn("[Database] Failed to connect:", error);
683
+ _db = null;
684
+ }
685
+ }
686
+ return _db;
687
+ }
688
+
689
+ export async function upsertUser(user: InsertUser): Promise<void> {
690
+ if (!user.openId) {
691
+ throw new Error("User openId is required for upsert");
692
+ }
693
+
694
+ const db = await getDb();
695
+ if (!db) {
696
+ console.warn("[Database] Cannot upsert user: database not available");
697
+ return;
698
+ }
699
+
700
+ try {
701
+ const values: InsertUser = {
702
+ openId: user.openId,
703
+ };
704
+ const updateSet: Record<string, unknown> = {};
705
+
706
+ const textFields = ["name", "email", "loginMethod"] as const;
707
+ type TextField = (typeof textFields)[number];
708
+
709
+ const assignNullable = (field: TextField) => {
710
+ const value = user[field];
711
+ if (value === undefined) return;
712
+ const normalized = value ?? null;
713
+ values[field] = normalized;
714
+ updateSet[field] = normalized;
715
+ };
716
+
717
+ textFields.forEach(assignNullable);
718
+
719
+ if (user.lastSignedIn !== undefined) {
720
+ values.lastSignedIn = user.lastSignedIn;
721
+ updateSet.lastSignedIn = user.lastSignedIn;
722
+ }
723
+ if (user.role !== undefined) {
724
+ values.role = user.role;
725
+ updateSet.role = user.role;
726
+ } else if (user.openId === ENV.ownerOpenId) {
727
+ values.role = "admin";
728
+ updateSet.role = "admin";
729
+ }
730
+
731
+ if (!values.lastSignedIn) {
732
+ values.lastSignedIn = new Date();
733
+ }
734
+
735
+ if (Object.keys(updateSet).length === 0) {
736
+ updateSet.lastSignedIn = new Date();
737
+ }
738
+
739
+ await db.insert(users).values(values).onDuplicateKeyUpdate({
740
+ set: updateSet,
741
+ });
742
+ } catch (error) {
743
+ console.error("[Database] Failed to upsert user:", error);
744
+ throw error;
745
+ }
746
+ }
747
+
748
+ export async function getUserByOpenId(openId: string) {
749
+ const db = await getDb();
750
+ if (!db) {
751
+ console.warn("[Database] Cannot get user: database not available");
752
+ return undefined;
753
+ }
754
+
755
+ const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
756
+
757
+ return result.length > 0 ? result[0] : undefined;
758
+ }
759
+
760
+ // TODO: add feature queries here as your schema grows.
761
+ ```
762
+
763
+ `server/routers.ts`
764
+ ```ts
765
+ import { COOKIE_NAME } from "../shared/const.js";
766
+ import { getSessionCookieOptions } from "./_core/cookies";
767
+ import { systemRouter } from "./_core/systemRouter";
768
+ import { publicProcedure, router } from "./_core/trpc";
769
+
770
+ export const appRouter = router({
771
+ // 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
772
+ system: systemRouter,
773
+ auth: router({
774
+ me: publicProcedure.query((opts) => opts.ctx.user),
775
+ logout: publicProcedure.mutation(({ ctx }) => {
776
+ const cookieOptions = getSessionCookieOptions(ctx.req);
777
+ ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
778
+ return {
779
+ success: true,
780
+ } as const;
781
+ }),
782
+ }),
783
+
784
+ // TODO: add feature routers here, e.g.
785
+ // todo: router({
786
+ // list: protectedProcedure.query(({ ctx }) =>
787
+ // db.getUserTodos(ctx.user.id)
788
+ // ),
789
+ // }),
790
+ });
791
+
792
+ export type AppRouter = typeof appRouter;
793
+ ```
794
+
795
+ `server/storage.ts`
796
+ ```ts
797
+ // Preconfigured storage helpers for Manus WebDev templates
798
+ // Uses the Biz-provided storage proxy (Authorization: Bearer <token>)
799
+
800
+ import { ENV } from "./_core/env";
801
+
802
+ type StorageConfig = { baseUrl: string; apiKey: string };
803
+
804
+ function getStorageConfig(): StorageConfig {
805
+ const baseUrl = ENV.forgeApiUrl;
806
+ const apiKey = ENV.forgeApiKey;
807
+
808
+ if (!baseUrl || !apiKey) {
809
+ throw new Error(
810
+ "Storage proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY",
811
+ );
812
+ }
813
+
814
+ return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
815
+ }
816
+
817
+ function buildUploadUrl(baseUrl: string, relKey: string): URL {
818
+ const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl));
819
+ url.searchParams.set("path", normalizeKey(relKey));
820
+ return url;
821
+ }
822
+
823
+ async function buildDownloadUrl(baseUrl: string, relKey: string, apiKey: string): Promise<string> {
824
+ const downloadApiUrl = new URL("v1/storage/downloadUrl", ensureTrailingSlash(baseUrl));
825
+ downloadApiUrl.searchParams.set("path", normalizeKey(relKey));
826
+ const response = await fetch(downloadApiUrl, {
827
+ method: "GET",
828
+ headers: buildAuthHeaders(apiKey),
829
+ });
830
+ return (await response.json()).url;
831
+ }
832
+
833
+ function ensureTrailingSlash(value: string): string {
834
+ return value.endsWith("/") ? value : `${value}/`;
835
+ }
836
+
837
+ function normalizeKey(relKey: string): string {
838
+ return relKey.replace(/^\/+/, "");
839
+ }
840
+
841
+ function toFormData(
842
+ data: Buffer | Uint8Array | string,
843
+ contentType: string,
844
+ fileName: string,
845
+ ): FormData {
846
+ const blob =
847
+ typeof data === "string"
848
+ ? new Blob([data], { type: contentType })
849
+ : new Blob([data as any], { type: contentType });
850
+ const form = new FormData();
851
+ form.append("file", blob, fileName || "file");
852
+ return form;
853
+ }
854
+
855
+ function buildAuthHeaders(apiKey: string): HeadersInit {
856
+ return { Authorization: `Bearer ${apiKey}` };
857
+ }
858
+
859
+ export async function storagePut(
860
+ relKey: string,
861
+ data: Buffer | Uint8Array | string,
862
+ contentType = "application/octet-stream",
863
+ ): Promise<{ key: string; url: string }> {
864
+ const { baseUrl, apiKey } = getStorageConfig();
865
+ const key = normalizeKey(relKey);
866
+ const uploadUrl = buildUploadUrl(baseUrl, key);
867
+ const formData = toFormData(data, contentType, key.split("/").pop() ?? key);
868
+ const response = await fetch(uploadUrl, {
869
+ method: "POST",
870
+ headers: buildAuthHeaders(apiKey),
871
+ body: formData,
872
+ });
873
+
874
+ if (!response.ok) {
875
+ const message = await response.text().catch(() => response.statusText);
876
+ throw new Error(
877
+ `Storage upload failed (${response.status} ${response.statusText}): ${message}`,
878
+ );
879
+ }
880
+ const url = (await response.json()).url;
881
+ return { key, url };
882
+ }
883
+
884
+ export async function storageGet(relKey: string): Promise<{ key: string; url: string }> {
885
+ const { baseUrl, apiKey } = getStorageConfig();
886
+ const key = normalizeKey(relKey);
887
+ return {
888
+ key,
889
+ url: await buildDownloadUrl(baseUrl, key, apiKey),
890
+ };
891
+ }
892
+ ```
893
+
894
+ `lib/trpc.ts`
895
+ ```ts
896
+ import { createTRPCReact } from "@trpc/react-query";
897
+ import { httpBatchLink } from "@trpc/client";
898
+ import superjson from "superjson";
899
+ import type { AppRouter } from "@/server/routers";
900
+ import { getApiBaseUrl } from "@/constants/oauth";
901
+ import * as Auth from "@/lib/_core/auth";
902
+
903
+ /**
904
+ * tRPC React client for type-safe API calls.
905
+ *
906
+ * IMPORTANT (tRPC v11): The `transformer` must be inside `httpBatchLink`,
907
+ * NOT at the root createClient level. This ensures client and server
908
+ * use the same serialization format (superjson).
909
+ */
910
+ export const trpc = createTRPCReact<AppRouter>();
911
+
912
+ /**
913
+ * Creates the tRPC client with proper configuration.
914
+ * Call this once in your app's root layout.
915
+ */
916
+ export function createTRPCClient() {
917
+ return trpc.createClient({
918
+ links: [
919
+ httpBatchLink({
920
+ url: `${getApiBaseUrl()}/api/trpc`,
921
+ // tRPC v11: transformer MUST be inside httpBatchLink, not at root
922
+ transformer: superjson,
923
+ async headers() {
924
+ const token = await Auth.getSessionToken();
925
+ return token ? { Authorization: `Bearer ${token}` } : {};
926
+ },
927
+ // Custom fetch to include credentials for cookie-based auth
928
+ fetch(url, options) {
929
+ return fetch(url, {
930
+ ...options,
931
+ credentials: "include",
932
+ });
933
+ },
934
+ }),
935
+ ],
936
+ });
937
+ }
938
+ ```
939
+
940
+ `hooks/use-auth.ts`
941
+ ```ts
942
+ import * as Api from "@/lib/_core/api";
943
+ import * as Auth from "@/lib/_core/auth";
944
+ import { useCallback, useEffect, useMemo, useState } from "react";
945
+ import { Platform } from "react-native";
946
+
947
+ type UseAuthOptions = {
948
+ autoFetch?: boolean;
949
+ };
950
+
951
+ export function useAuth(options?: UseAuthOptions) {
952
+ const { autoFetch = true } = options ?? {};
953
+ const [user, setUser] = useState<Auth.User | null>(null);
954
+ const [loading, setLoading] = useState(true);
955
+ const [error, setError] = useState<Error | null>(null);
956
+
957
+ const fetchUser = useCallback(async () => {
958
+ console.log("[useAuth] fetchUser called");
959
+ try {
960
+ setLoading(true);
961
+ setError(null);
962
+
963
+ // Web platform: use cookie-based auth, fetch user from API
964
+ if (Platform.OS === "web") {
965
+ console.log("[useAuth] Web platform: fetching user from API...");
966
+ const apiUser = await Api.getMe();
967
+ console.log("[useAuth] API user response:", apiUser);
968
+
969
+ if (apiUser) {
970
+ const userInfo: Auth.User = {
971
+ id: apiUser.id,
972
+ openId: apiUser.openId,
973
+ name: apiUser.name,
974
+ email: apiUser.email,
975
+ loginMethod: apiUser.loginMethod,
976
+ lastSignedIn: new Date(apiUser.lastSignedIn),
977
+ };
978
+ setUser(userInfo);
979
+ // Cache user info in localStorage for faster subsequent loads
980
+ await Auth.setUserInfo(userInfo);
981
+ console.log("[useAuth] Web user set from API:", userInfo);
982
+ } else {
983
+ console.log("[useAuth] Web: No authenticated user from API");
984
+ setUser(null);
985
+ await Auth.clearUserInfo();
986
+ }
987
+ return;
988
+ }
989
+
990
+ // Native platform: use token-based auth
991
+ console.log("[useAuth] Native platform: checking for session token...");
992
+ const sessionToken = await Auth.getSessionToken();
993
+ console.log(
994
+ "[useAuth] Session token:",
995
+ sessionToken ? `present (${sessionToken.substring(0, 20)}...)` : "missing",
996
+ );
997
+ if (!sessionToken) {
998
+ console.log("[useAuth] No session token, setting user to null");
999
+ setUser(null);
1000
+ return;
1001
+ }
1002
+
1003
+ // Use cached user info for native (token validates the session)
1004
+ const cachedUser = await Auth.getUserInfo();
1005
+ console.log("[useAuth] Cached user:", cachedUser);
1006
+ if (cachedUser) {
1007
+ console.log("[useAuth] Using cached user info");
1008
+ setUser(cachedUser);
1009
+ } else {
1010
+ console.log("[useAuth] No cached user, setting user to null");
1011
+ setUser(null);
1012
+ }
1013
+ } catch (err) {
1014
+ const error = err instanceof Error ? err : new Error("Failed to fetch user");
1015
+ console.error("[useAuth] fetchUser error:", error);
1016
+ setError(error);
1017
+ setUser(null);
1018
+ } finally {
1019
+ setLoading(false);
1020
+ console.log("[useAuth] fetchUser completed, loading:", false);
1021
+ }
1022
+ }, []);
1023
+
1024
+ const logout = useCallback(async () => {
1025
+ try {
1026
+ await Api.logout();
1027
+ } catch (err) {
1028
+ console.error("[Auth] Logout API call failed:", err);
1029
+ // Continue with logout even if API call fails
1030
+ } finally {
1031
+ await Auth.removeSessionToken();
1032
+ await Auth.clearUserInfo();
1033
+ setUser(null);
1034
+ setError(null);
1035
+ }
1036
+ }, []);
1037
+
1038
+ const isAuthenticated = useMemo(() => Boolean(user), [user]);
1039
+
1040
+ useEffect(() => {
1041
+ console.log("[useAuth] useEffect triggered, autoFetch:", autoFetch, "platform:", Platform.OS);
1042
+ if (autoFetch) {
1043
+ if (Platform.OS === "web") {
1044
+ // Web: fetch user from API directly (user will login manually if needed)
1045
+ console.log("[useAuth] Web: fetching user from API...");
1046
+ fetchUser();
1047
+ } else {
1048
+ // Native: check for cached user info first for faster initial load
1049
+ Auth.getUserInfo().then((cachedUser) => {
1050
+ console.log("[useAuth] Native cached user check:", cachedUser);
1051
+ if (cachedUser) {
1052
+ console.log("[useAuth] Native: setting cached user immediately");
1053
+ setUser(cachedUser);
1054
+ setLoading(false);
1055
+ } else {
1056
+ // No cached user, check session token
1057
+ fetchUser();
1058
+ }
1059
+ });
1060
+ }
1061
+ } else {
1062
+ console.log("[useAuth] autoFetch disabled, setting loading to false");
1063
+ setLoading(false);
1064
+ }
1065
+ }, [autoFetch, fetchUser]);
1066
+
1067
+ useEffect(() => {
1068
+ console.log("[useAuth] State updated:", {
1069
+ hasUser: !!user,
1070
+ loading,
1071
+ isAuthenticated,
1072
+ error: error?.message,
1073
+ });
1074
+ }, [user, loading, isAuthenticated, error]);
1075
+
1076
+ return {
1077
+ user,
1078
+ loading,
1079
+ error,
1080
+ isAuthenticated,
1081
+ refresh: fetchUser,
1082
+ logout,
1083
+ };
1084
+ }
1085
+ ```
1086
+
1087
+ `tests/auth.logout.test.ts`
1088
+ ```ts
1089
+ import { describe, expect, it } from "vitest";
1090
+ import { appRouter } from "../server/routers";
1091
+ import { COOKIE_NAME } from "../shared/const";
1092
+ import type { TrpcContext } from "../server/_core/context";
1093
+
1094
+ type CookieCall = {
1095
+ name: string;
1096
+ options: Record<string, unknown>;
1097
+ };
1098
+
1099
+ type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
1100
+
1101
+ function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] } {
1102
+ const clearedCookies: CookieCall[] = [];
1103
+
1104
+ const user: AuthenticatedUser = {
1105
+ id: 1,
1106
+ openId: "sample-user",
1107
+ email: "sample@example.com",
1108
+ name: "Sample User",
1109
+ loginMethod: "manus",
1110
+ role: "user",
1111
+ createdAt: new Date(),
1112
+ updatedAt: new Date(),
1113
+ lastSignedIn: new Date(),
1114
+ };
1115
+
1116
+ const ctx: TrpcContext = {
1117
+ user,
1118
+ req: {
1119
+ protocol: "https",
1120
+ headers: {},
1121
+ } as TrpcContext["req"],
1122
+ res: {
1123
+ clearCookie: (name: string, options: Record<string, unknown>) => {
1124
+ clearedCookies.push({ name, options });
1125
+ },
1126
+ } as TrpcContext["res"],
1127
+ };
1128
+
1129
+ return { ctx, clearedCookies };
1130
+ }
1131
+
1132
+ // TODO: Remove `.skip` below once you implement user authentication
1133
+ describe.skip("auth.logout", () => {
1134
+ it("clears the session cookie and reports success", async () => {
1135
+ const { ctx, clearedCookies } = createAuthContext();
1136
+ const caller = appRouter.createCaller(ctx);
1137
+
1138
+ const result = await caller.auth.logout();
1139
+
1140
+ expect(result).toEqual({ success: true });
1141
+ expect(clearedCookies).toHaveLength(1);
1142
+ expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);
1143
+ expect(clearedCookies[0]?.options).toMatchObject({
1144
+ maxAge: -1,
1145
+ secure: true,
1146
+ sameSite: "none",
1147
+ httpOnly: true,
1148
+ path: "/",
1149
+ });
1150
+ });
1151
+ });
1152
+ ```
1153
+
1154
+ ---
1155
+
1156
+ ## Common Patterns
1157
+
1158
+ ### Optimistic Updates
1159
+
1160
+ Update UI immediately, revert on error:
1161
+
1162
+ ```tsx
1163
+ const toggleComplete = trpc.items.update.useMutation({
1164
+ onMutate: async (input) => {
1165
+ // Cancel outgoing queries
1166
+ await utils.items.list.cancel();
1167
+
1168
+ // Snapshot previous value
1169
+ const previous = utils.items.list.getData();
1170
+
1171
+ // Optimistically update
1172
+ utils.items.list.setData(undefined, (old) =>
1173
+ old?.map((item) =>
1174
+ item.id === input.id
1175
+ ? { ...item, completed: input.completed }
1176
+ : item
1177
+ )
1178
+ );
1179
+
1180
+ return { previous };
1181
+ },
1182
+ onError: (err, input, context) => {
1183
+ // Revert on error
1184
+ utils.items.list.setData(undefined, context?.previous);
1185
+ },
1186
+ onSettled: () => {
1187
+ // Refetch after mutation
1188
+ utils.items.list.invalidate();
1189
+ },
1190
+ });
1191
+ ```
1192
+
1193
+ ### Pagination
1194
+
1195
+ ```tsx
1196
+ // Router
1197
+ list: protectedProcedure
1198
+ .input(z.object({
1199
+ limit: z.number().min(1).max(100).default(20),
1200
+ cursor: z.number().optional(),
1201
+ }))
1202
+ .query(async ({ ctx, input }) => {
1203
+ const items = await db.getItems({
1204
+ userId: ctx.user.id,
1205
+ limit: input.limit + 1,
1206
+ cursor: input.cursor,
1207
+ });
1208
+
1209
+ let nextCursor: number | undefined;
1210
+ if (items.length > input.limit) {
1211
+ const next = items.pop();
1212
+ nextCursor = next?.id;
1213
+ }
1214
+
1215
+ return { items, nextCursor };
1216
+ }),
1217
+
1218
+ // Frontend
1219
+ const { data, fetchNextPage, hasNextPage } = trpc.items.list.useInfiniteQuery(
1220
+ { limit: 20 },
1221
+ { getNextPageParam: (lastPage) => lastPage.nextCursor }
1222
+ );
1223
+ ```
1224
+
1225
+ ---
1226
+
1227
+ ## Troubleshooting
1228
+
1229
+ | Issue | Solution |
1230
+ |-------|----------|
1231
+ | "Database not available" | Check `DATABASE_URL` is set |
1232
+ | Auth not working | Verify OAuth callback URL matches |
1233
+ | tRPC type errors | Run `pnpm check` to verify types |
1234
+ | Mutations fail silently | Check browser console for errors |
1235
+ | Session expired | User needs to login again |
server/_core/context.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
2
+ import type { User } from "../../drizzle/schema";
3
+ import { sdk } from "./sdk";
4
+
5
+ export type TrpcContext = {
6
+ req: CreateExpressContextOptions["req"];
7
+ res: CreateExpressContextOptions["res"];
8
+ user: User | null;
9
+ };
10
+
11
+ export async function createContext(opts: CreateExpressContextOptions): Promise<TrpcContext> {
12
+ let user: User | null = null;
13
+
14
+ try {
15
+ user = await sdk.authenticateRequest(opts.req);
16
+ } catch (error) {
17
+ // Authentication is optional for public procedures.
18
+ user = null;
19
+ }
20
+
21
+ return {
22
+ req: opts.req,
23
+ res: opts.res,
24
+ user,
25
+ };
26
+ }
server/_core/cookies.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CookieOptions, Request } from "express";
2
+
3
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
4
+
5
+ function isIpAddress(host: string) {
6
+ // Basic IPv4 check and IPv6 presence detection.
7
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
8
+ return host.includes(":");
9
+ }
10
+
11
+ function isSecureRequest(req: Request) {
12
+ if (req.protocol === "https") return true;
13
+
14
+ const forwardedProto = req.headers["x-forwarded-proto"];
15
+ if (!forwardedProto) return false;
16
+
17
+ const protoList = Array.isArray(forwardedProto) ? forwardedProto : forwardedProto.split(",");
18
+
19
+ return protoList.some((proto) => proto.trim().toLowerCase() === "https");
20
+ }
21
+
22
+ /**
23
+ * Extract parent domain for cookie sharing across subdomains.
24
+ * e.g., "3000-xxx.manuspre.computer" -> ".manuspre.computer"
25
+ * This allows cookies set by 3000-xxx to be read by 8081-xxx
26
+ */
27
+ function getParentDomain(hostname: string): string | undefined {
28
+ // Don't set domain for localhost or IP addresses
29
+ if (LOCAL_HOSTS.has(hostname) || isIpAddress(hostname)) {
30
+ return undefined;
31
+ }
32
+
33
+ // Split hostname into parts
34
+ const parts = hostname.split(".");
35
+
36
+ // Need at least 3 parts for a subdomain (e.g., "3000-xxx.manuspre.computer")
37
+ // For "manuspre.computer", we can't set a parent domain
38
+ if (parts.length < 3) {
39
+ return undefined;
40
+ }
41
+
42
+ // Return parent domain with leading dot (e.g., ".manuspre.computer")
43
+ // This allows cookie to be shared across all subdomains
44
+ return "." + parts.slice(-2).join(".");
45
+ }
46
+
47
+ export function getSessionCookieOptions(
48
+ req: Request,
49
+ ): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
50
+ const hostname = req.hostname;
51
+ const domain = getParentDomain(hostname);
52
+
53
+ return {
54
+ domain,
55
+ httpOnly: true,
56
+ path: "/",
57
+ sameSite: "none",
58
+ secure: isSecureRequest(req),
59
+ };
60
+ }
server/_core/dataApi.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Quick example (matches curl usage):
3
+ * await callDataApi("Youtube/search", {
4
+ * query: { gl: "US", hl: "en", q: "manus" },
5
+ * })
6
+ */
7
+ import { ENV } from "./env";
8
+
9
+ export type DataApiCallOptions = {
10
+ query?: Record<string, unknown>;
11
+ body?: Record<string, unknown>;
12
+ pathParams?: Record<string, unknown>;
13
+ formData?: Record<string, unknown>;
14
+ };
15
+
16
+ export async function callDataApi(
17
+ apiId: string,
18
+ options: DataApiCallOptions = {},
19
+ ): Promise<unknown> {
20
+ if (!ENV.forgeApiUrl) {
21
+ throw new Error("BUILT_IN_FORGE_API_URL is not configured");
22
+ }
23
+ if (!ENV.forgeApiKey) {
24
+ throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
25
+ }
26
+
27
+ // Build the full URL by appending the service path to the base URL
28
+ const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`;
29
+ const fullUrl = new URL("webdevtoken.v1.WebDevService/CallApi", baseUrl).toString();
30
+
31
+ const response = await fetch(fullUrl, {
32
+ method: "POST",
33
+ headers: {
34
+ accept: "application/json",
35
+ "content-type": "application/json",
36
+ "connect-protocol-version": "1",
37
+ authorization: `Bearer ${ENV.forgeApiKey}`,
38
+ },
39
+ body: JSON.stringify({
40
+ apiId,
41
+ query: options.query,
42
+ body: options.body,
43
+ path_params: options.pathParams,
44
+ multipart_form_data: options.formData,
45
+ }),
46
+ });
47
+
48
+ if (!response.ok) {
49
+ const detail = await response.text().catch(() => "");
50
+ throw new Error(
51
+ `Data API request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`,
52
+ );
53
+ }
54
+
55
+ const payload = await response.json().catch(() => ({}));
56
+ if (payload && typeof payload === "object" && "jsonData" in payload) {
57
+ try {
58
+ return JSON.parse((payload as Record<string, string>).jsonData ?? "{}");
59
+ } catch {
60
+ return (payload as Record<string, unknown>).jsonData;
61
+ }
62
+ }
63
+ return payload;
64
+ }
server/_core/env.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ENV = {
2
+ appId: process.env.VITE_APP_ID ?? "",
3
+ cookieSecret: process.env.JWT_SECRET ?? "",
4
+ databaseUrl: process.env.DATABASE_URL ?? "",
5
+ oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
6
+ ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
7
+ isProduction: process.env.NODE_ENV === "production",
8
+ forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
9
+ forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
10
+ };
server/_core/imageGeneration.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Image generation helper using internal ImageService
3
+ *
4
+ * Example usage:
5
+ * const { url: imageUrl } = await generateImage({
6
+ * prompt: "A serene landscape with mountains"
7
+ * });
8
+ *
9
+ * For editing:
10
+ * const { url: imageUrl } = await generateImage({
11
+ * prompt: "Add a rainbow to this landscape",
12
+ * originalImages: [{
13
+ * url: "https://example.com/original.jpg",
14
+ * mimeType: "image/jpeg"
15
+ * }]
16
+ * });
17
+ */
18
+ import { storagePut } from "../storage";
19
+ import { ENV } from "./env";
20
+
21
+ export type GenerateImageOptions = {
22
+ prompt: string;
23
+ originalImages?: Array<{
24
+ url?: string;
25
+ b64Json?: string;
26
+ mimeType?: string;
27
+ }>;
28
+ };
29
+
30
+ export type GenerateImageResponse = {
31
+ url?: string;
32
+ };
33
+
34
+ export async function generateImage(options: GenerateImageOptions): Promise<GenerateImageResponse> {
35
+ if (!ENV.forgeApiUrl) {
36
+ throw new Error("BUILT_IN_FORGE_API_URL is not configured");
37
+ }
38
+ if (!ENV.forgeApiKey) {
39
+ throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
40
+ }
41
+
42
+ // Build the full URL by appending the service path to the base URL
43
+ const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`;
44
+ const fullUrl = new URL("images.v1.ImageService/GenerateImage", baseUrl).toString();
45
+
46
+ const response = await fetch(fullUrl, {
47
+ method: "POST",
48
+ headers: {
49
+ accept: "application/json",
50
+ "content-type": "application/json",
51
+ "connect-protocol-version": "1",
52
+ authorization: `Bearer ${ENV.forgeApiKey}`,
53
+ },
54
+ body: JSON.stringify({
55
+ prompt: options.prompt,
56
+ original_images: options.originalImages || [],
57
+ }),
58
+ });
59
+
60
+ if (!response.ok) {
61
+ const detail = await response.text().catch(() => "");
62
+ throw new Error(
63
+ `Image generation request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`,
64
+ );
65
+ }
66
+
67
+ const result = (await response.json()) as {
68
+ image: {
69
+ b64Json: string;
70
+ mimeType: string;
71
+ };
72
+ };
73
+ const base64Data = result.image.b64Json;
74
+ const buffer = Buffer.from(base64Data, "base64");
75
+
76
+ // Save to S3
77
+ const { url } = await storagePut(`generated/${Date.now()}.png`, buffer, result.image.mimeType);
78
+ return {
79
+ url,
80
+ };
81
+ }
server/_core/index.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "dotenv/config";
2
+ import express from "express";
3
+ import { createServer } from "http";
4
+ import net from "net";
5
+ import { createExpressMiddleware } from "@trpc/server/adapters/express";
6
+ import { registerOAuthRoutes } from "./oauth";
7
+ import { appRouter } from "../routers";
8
+ import { createContext } from "./context";
9
+ import { registerVoiceWebSocket } from "../voice-ws";
10
+
11
+ function isPortAvailable(port: number): Promise<boolean> {
12
+ return new Promise((resolve) => {
13
+ const server = net.createServer();
14
+ server.listen(port, "0.0.0.0", () => {
15
+ server.close(() => resolve(true));
16
+ });
17
+ server.on("error", () => resolve(false));
18
+ });
19
+ }
20
+
21
+ async function findAvailablePort(startPort: number = 3000): Promise<number> {
22
+ for (let port = startPort; port < startPort + 20; port++) {
23
+ if (await isPortAvailable(port)) {
24
+ return port;
25
+ }
26
+ }
27
+ throw new Error(`No available port found starting from ${startPort}`);
28
+ }
29
+
30
+ async function startServer() {
31
+ const app = express();
32
+ const server = createServer(app);
33
+
34
+ // Enable CORS for all routes
35
+ // In production, restrict to known origins; in dev, reflect any origin.
36
+ const ALLOWED_ORIGINS = process.env.CORS_ORIGINS
37
+ ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim())
38
+ : null; // null = allow all (dev mode)
39
+
40
+ app.use((req, res, next) => {
41
+ const origin = req.headers.origin;
42
+ if (origin) {
43
+ const isAllowed = !ALLOWED_ORIGINS || ALLOWED_ORIGINS.includes(origin);
44
+ if (isAllowed) {
45
+ res.header("Access-Control-Allow-Origin", origin);
46
+ }
47
+ }
48
+ res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
49
+ res.header(
50
+ "Access-Control-Allow-Headers",
51
+ "Origin, X-Requested-With, Content-Type, Accept, Authorization",
52
+ );
53
+ res.header("Access-Control-Allow-Credentials", "true");
54
+
55
+ // Handle preflight requests
56
+ if (req.method === "OPTIONS") {
57
+ res.sendStatus(200);
58
+ return;
59
+ }
60
+ next();
61
+ });
62
+
63
+ app.use(express.json({ limit: "50mb" }));
64
+ app.use(express.urlencoded({ limit: "50mb", extended: true }));
65
+
66
+ registerOAuthRoutes(app);
67
+
68
+ app.get("/api/health", (_req, res) => {
69
+ res.json({ ok: true, timestamp: Date.now() });
70
+ });
71
+
72
+ app.use(
73
+ "/api/trpc",
74
+ createExpressMiddleware({
75
+ router: appRouter,
76
+ createContext,
77
+ }),
78
+ );
79
+
80
+ const preferredPort = parseInt(process.env.PORT || "3000");
81
+ const port = await findAvailablePort(preferredPort);
82
+
83
+ if (port !== preferredPort) {
84
+ console.warn(`⚠️ Port ${preferredPort} is busy, using port ${port} instead`);
85
+ console.warn(`⚠️ Update EXPO_PUBLIC_API_BASE_URL in .env to use port ${port}`);
86
+ }
87
+
88
+ // تسجيل WebSocket للتعرف الفوري على الصوت
89
+ registerVoiceWebSocket(server);
90
+
91
+ server.listen(port, "0.0.0.0", () => {
92
+ console.log(`[api] server listening on http://0.0.0.0:${port}`);
93
+ console.log(`[api] LAN clients should connect to http://<YOUR_PC_IP>:${port}`);
94
+ });
95
+ }
96
+
97
+ startServer().catch(console.error);
server/_core/llm.ts ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ENV } from "./env";
2
+
3
+ export type Role = "system" | "user" | "assistant" | "tool" | "function";
4
+
5
+ export type TextContent = {
6
+ type: "text";
7
+ text: string;
8
+ };
9
+
10
+ export type ImageContent = {
11
+ type: "image_url";
12
+ image_url: {
13
+ url: string;
14
+ detail?: "auto" | "low" | "high";
15
+ };
16
+ };
17
+
18
+ export type FileContent = {
19
+ type: "file_url";
20
+ file_url: {
21
+ url: string;
22
+ mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4";
23
+ };
24
+ };
25
+
26
+ export type MessageContent = string | TextContent | ImageContent | FileContent;
27
+
28
+ export type Message = {
29
+ role: Role;
30
+ content: MessageContent | MessageContent[];
31
+ name?: string;
32
+ tool_call_id?: string;
33
+ };
34
+
35
+ export type Tool = {
36
+ type: "function";
37
+ function: {
38
+ name: string;
39
+ description?: string;
40
+ parameters?: Record<string, unknown>;
41
+ };
42
+ };
43
+
44
+ export type ToolChoicePrimitive = "none" | "auto" | "required";
45
+ export type ToolChoiceByName = { name: string };
46
+ export type ToolChoiceExplicit = {
47
+ type: "function";
48
+ function: {
49
+ name: string;
50
+ };
51
+ };
52
+
53
+ export type ToolChoice = ToolChoicePrimitive | ToolChoiceByName | ToolChoiceExplicit;
54
+
55
+ export type InvokeParams = {
56
+ messages: Message[];
57
+ tools?: Tool[];
58
+ toolChoice?: ToolChoice;
59
+ tool_choice?: ToolChoice;
60
+ maxTokens?: number;
61
+ max_tokens?: number;
62
+ outputSchema?: OutputSchema;
63
+ output_schema?: OutputSchema;
64
+ responseFormat?: ResponseFormat;
65
+ response_format?: ResponseFormat;
66
+ };
67
+
68
+ export type ToolCall = {
69
+ id: string;
70
+ type: "function";
71
+ function: {
72
+ name: string;
73
+ arguments: string;
74
+ };
75
+ };
76
+
77
+ export type InvokeResult = {
78
+ id: string;
79
+ created: number;
80
+ model: string;
81
+ choices: Array<{
82
+ index: number;
83
+ message: {
84
+ role: Role;
85
+ content: string | Array<TextContent | ImageContent | FileContent>;
86
+ tool_calls?: ToolCall[];
87
+ };
88
+ finish_reason: string | null;
89
+ }>;
90
+ usage?: {
91
+ prompt_tokens: number;
92
+ completion_tokens: number;
93
+ total_tokens: number;
94
+ };
95
+ };
96
+
97
+ export type JsonSchema = {
98
+ name: string;
99
+ schema: Record<string, unknown>;
100
+ strict?: boolean;
101
+ };
102
+
103
+ export type OutputSchema = JsonSchema;
104
+
105
+ export type ResponseFormat =
106
+ | { type: "text" }
107
+ | { type: "json_object" }
108
+ | { type: "json_schema"; json_schema: JsonSchema };
109
+
110
+ const ensureArray = (value: MessageContent | MessageContent[]): MessageContent[] =>
111
+ Array.isArray(value) ? value : [value];
112
+
113
+ const normalizeContentPart = (part: MessageContent): TextContent | ImageContent | FileContent => {
114
+ if (typeof part === "string") {
115
+ return { type: "text", text: part };
116
+ }
117
+
118
+ if (part.type === "text") {
119
+ return part;
120
+ }
121
+
122
+ if (part.type === "image_url") {
123
+ return part;
124
+ }
125
+
126
+ if (part.type === "file_url") {
127
+ return part;
128
+ }
129
+
130
+ throw new Error("Unsupported message content part");
131
+ };
132
+
133
+ const normalizeMessage = (message: Message) => {
134
+ const { role, name, tool_call_id } = message;
135
+
136
+ if (role === "tool" || role === "function") {
137
+ const content = ensureArray(message.content)
138
+ .map((part) => (typeof part === "string" ? part : JSON.stringify(part)))
139
+ .join("\n");
140
+
141
+ return {
142
+ role,
143
+ name,
144
+ tool_call_id,
145
+ content,
146
+ };
147
+ }
148
+
149
+ const contentParts = ensureArray(message.content).map(normalizeContentPart);
150
+
151
+ // If there's only text content, collapse to a single string for compatibility
152
+ if (contentParts.length === 1 && contentParts[0].type === "text") {
153
+ return {
154
+ role,
155
+ name,
156
+ content: contentParts[0].text,
157
+ };
158
+ }
159
+
160
+ return {
161
+ role,
162
+ name,
163
+ content: contentParts,
164
+ };
165
+ };
166
+
167
+ const normalizeToolChoice = (
168
+ toolChoice: ToolChoice | undefined,
169
+ tools: Tool[] | undefined,
170
+ ): "none" | "auto" | ToolChoiceExplicit | undefined => {
171
+ if (!toolChoice) return undefined;
172
+
173
+ if (toolChoice === "none" || toolChoice === "auto") {
174
+ return toolChoice;
175
+ }
176
+
177
+ if (toolChoice === "required") {
178
+ if (!tools || tools.length === 0) {
179
+ throw new Error("tool_choice 'required' was provided but no tools were configured");
180
+ }
181
+
182
+ if (tools.length > 1) {
183
+ throw new Error(
184
+ "tool_choice 'required' needs a single tool or specify the tool name explicitly",
185
+ );
186
+ }
187
+
188
+ return {
189
+ type: "function",
190
+ function: { name: tools[0].function.name },
191
+ };
192
+ }
193
+
194
+ if ("name" in toolChoice) {
195
+ return {
196
+ type: "function",
197
+ function: { name: toolChoice.name },
198
+ };
199
+ }
200
+
201
+ return toolChoice;
202
+ };
203
+
204
+ const resolveApiUrl = () =>
205
+ ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0
206
+ ? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions`
207
+ : "https://forge.manus.im/v1/chat/completions";
208
+
209
+ const assertApiKey = () => {
210
+ if (!ENV.forgeApiKey) {
211
+ throw new Error("OPENAI_API_KEY is not configured");
212
+ }
213
+ };
214
+
215
+ const normalizeResponseFormat = ({
216
+ responseFormat,
217
+ response_format,
218
+ outputSchema,
219
+ output_schema,
220
+ }: {
221
+ responseFormat?: ResponseFormat;
222
+ response_format?: ResponseFormat;
223
+ outputSchema?: OutputSchema;
224
+ output_schema?: OutputSchema;
225
+ }):
226
+ | { type: "json_schema"; json_schema: JsonSchema }
227
+ | { type: "text" }
228
+ | { type: "json_object" }
229
+ | undefined => {
230
+ const explicitFormat = responseFormat || response_format;
231
+ if (explicitFormat) {
232
+ if (explicitFormat.type === "json_schema" && !explicitFormat.json_schema?.schema) {
233
+ throw new Error("responseFormat json_schema requires a defined schema object");
234
+ }
235
+ return explicitFormat;
236
+ }
237
+
238
+ const schema = outputSchema || output_schema;
239
+ if (!schema) return undefined;
240
+
241
+ if (!schema.name || !schema.schema) {
242
+ throw new Error("outputSchema requires both name and schema");
243
+ }
244
+
245
+ return {
246
+ type: "json_schema",
247
+ json_schema: {
248
+ name: schema.name,
249
+ schema: schema.schema,
250
+ ...(typeof schema.strict === "boolean" ? { strict: schema.strict } : {}),
251
+ },
252
+ };
253
+ };
254
+
255
+ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
256
+ assertApiKey();
257
+
258
+ const {
259
+ messages,
260
+ tools,
261
+ toolChoice,
262
+ tool_choice,
263
+ outputSchema,
264
+ output_schema,
265
+ responseFormat,
266
+ response_format,
267
+ } = params;
268
+
269
+ const payload: Record<string, unknown> = {
270
+ model: "gemini-2.5-flash",
271
+ messages: messages.map(normalizeMessage),
272
+ };
273
+
274
+ if (tools && tools.length > 0) {
275
+ payload.tools = tools;
276
+ }
277
+
278
+ const normalizedToolChoice = normalizeToolChoice(toolChoice || tool_choice, tools);
279
+ if (normalizedToolChoice) {
280
+ payload.tool_choice = normalizedToolChoice;
281
+ }
282
+
283
+ payload.max_tokens = 32768;
284
+ payload.thinking = {
285
+ budget_tokens: 128,
286
+ };
287
+
288
+ const normalizedResponseFormat = normalizeResponseFormat({
289
+ responseFormat,
290
+ response_format,
291
+ outputSchema,
292
+ output_schema,
293
+ });
294
+
295
+ if (normalizedResponseFormat) {
296
+ payload.response_format = normalizedResponseFormat;
297
+ }
298
+
299
+ const response = await fetch(resolveApiUrl(), {
300
+ method: "POST",
301
+ headers: {
302
+ "content-type": "application/json",
303
+ authorization: `Bearer ${ENV.forgeApiKey}`,
304
+ },
305
+ body: JSON.stringify(payload),
306
+ });
307
+
308
+ if (!response.ok) {
309
+ const errorText = await response.text();
310
+ throw new Error(`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`);
311
+ }
312
+
313
+ return (await response.json()) as InvokeResult;
314
+ }
server/_core/notification.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TRPCError } from "@trpc/server";
2
+ import { ENV } from "./env";
3
+
4
+ export type NotificationPayload = {
5
+ title: string;
6
+ content: string;
7
+ };
8
+
9
+ const TITLE_MAX_LENGTH = 1200;
10
+ const CONTENT_MAX_LENGTH = 20000;
11
+
12
+ const trimValue = (value: string): string => value.trim();
13
+ const isNonEmptyString = (value: unknown): value is string =>
14
+ typeof value === "string" && value.trim().length > 0;
15
+
16
+ const buildEndpointUrl = (baseUrl: string): string => {
17
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
18
+ return new URL("webdevtoken.v1.WebDevService/SendNotification", normalizedBase).toString();
19
+ };
20
+
21
+ const validatePayload = (input: NotificationPayload): NotificationPayload => {
22
+ if (!isNonEmptyString(input.title)) {
23
+ throw new TRPCError({
24
+ code: "BAD_REQUEST",
25
+ message: "Notification title is required.",
26
+ });
27
+ }
28
+ if (!isNonEmptyString(input.content)) {
29
+ throw new TRPCError({
30
+ code: "BAD_REQUEST",
31
+ message: "Notification content is required.",
32
+ });
33
+ }
34
+
35
+ const title = trimValue(input.title);
36
+ const content = trimValue(input.content);
37
+
38
+ if (title.length > TITLE_MAX_LENGTH) {
39
+ throw new TRPCError({
40
+ code: "BAD_REQUEST",
41
+ message: `Notification title must be at most ${TITLE_MAX_LENGTH} characters.`,
42
+ });
43
+ }
44
+
45
+ if (content.length > CONTENT_MAX_LENGTH) {
46
+ throw new TRPCError({
47
+ code: "BAD_REQUEST",
48
+ message: `Notification content must be at most ${CONTENT_MAX_LENGTH} characters.`,
49
+ });
50
+ }
51
+
52
+ return { title, content };
53
+ };
54
+
55
+ /**
56
+ * Dispatches a project-owner notification through the Manus Notification Service.
57
+ * Returns `true` if the request was accepted, `false` when the upstream service
58
+ * cannot be reached (callers can fall back to email/slack). Validation errors
59
+ * bubble up as TRPC errors so callers can fix the payload.
60
+ */
61
+ export async function notifyOwner(payload: NotificationPayload): Promise<boolean> {
62
+ const { title, content } = validatePayload(payload);
63
+
64
+ if (!ENV.forgeApiUrl) {
65
+ throw new TRPCError({
66
+ code: "INTERNAL_SERVER_ERROR",
67
+ message: "Notification service URL is not configured.",
68
+ });
69
+ }
70
+
71
+ if (!ENV.forgeApiKey) {
72
+ throw new TRPCError({
73
+ code: "INTERNAL_SERVER_ERROR",
74
+ message: "Notification service API key is not configured.",
75
+ });
76
+ }
77
+
78
+ const endpoint = buildEndpointUrl(ENV.forgeApiUrl);
79
+
80
+ try {
81
+ const response = await fetch(endpoint, {
82
+ method: "POST",
83
+ headers: {
84
+ accept: "application/json",
85
+ authorization: `Bearer ${ENV.forgeApiKey}`,
86
+ "content-type": "application/json",
87
+ "connect-protocol-version": "1",
88
+ },
89
+ body: JSON.stringify({ title, content }),
90
+ });
91
+
92
+ if (!response.ok) {
93
+ const detail = await response.text().catch(() => "");
94
+ console.warn(
95
+ `[Notification] Failed to notify owner (${response.status} ${response.statusText})${
96
+ detail ? `: ${detail}` : ""
97
+ }`,
98
+ );
99
+ return false;
100
+ }
101
+
102
+ return true;
103
+ } catch (error) {
104
+ console.warn("[Notification] Error calling notification service:", error);
105
+ return false;
106
+ }
107
+ }
server/_core/oauth.ts ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { COOKIE_NAME, ONE_YEAR_MS } from "../../shared/const.js";
2
+ import type { Express, Request, Response } from "express";
3
+ import { getUserByOpenId, upsertUser } from "../db";
4
+ import { getSessionCookieOptions } from "./cookies";
5
+ import { sdk } from "./sdk";
6
+
7
+ function getQueryParam(req: Request, key: string): string | undefined {
8
+ const value = req.query[key];
9
+ return typeof value === "string" ? value : undefined;
10
+ }
11
+
12
+ async function syncUser(userInfo: {
13
+ openId?: string | null;
14
+ name?: string | null;
15
+ email?: string | null;
16
+ loginMethod?: string | null;
17
+ platform?: string | null;
18
+ }) {
19
+ if (!userInfo.openId) {
20
+ throw new Error("openId missing from user info");
21
+ }
22
+
23
+ const lastSignedIn = new Date();
24
+ await upsertUser({
25
+ openId: userInfo.openId,
26
+ name: userInfo.name || null,
27
+ email: userInfo.email ?? null,
28
+ loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
29
+ lastSignedIn,
30
+ });
31
+ const saved = await getUserByOpenId(userInfo.openId);
32
+ return (
33
+ saved ?? {
34
+ openId: userInfo.openId,
35
+ name: userInfo.name,
36
+ email: userInfo.email,
37
+ loginMethod: userInfo.loginMethod ?? null,
38
+ lastSignedIn,
39
+ }
40
+ );
41
+ }
42
+
43
+ function buildUserResponse(
44
+ user:
45
+ | Awaited<ReturnType<typeof getUserByOpenId>>
46
+ | {
47
+ openId: string;
48
+ name?: string | null;
49
+ email?: string | null;
50
+ loginMethod?: string | null;
51
+ lastSignedIn?: Date | null;
52
+ },
53
+ ) {
54
+ return {
55
+ id: (user as any)?.id ?? null,
56
+ openId: user?.openId ?? null,
57
+ name: user?.name ?? null,
58
+ email: user?.email ?? null,
59
+ loginMethod: user?.loginMethod ?? null,
60
+ lastSignedIn: (user?.lastSignedIn ?? new Date()).toISOString(),
61
+ };
62
+ }
63
+
64
+ export function registerOAuthRoutes(app: Express) {
65
+ app.get("/api/oauth/callback", async (req: Request, res: Response) => {
66
+ const code = getQueryParam(req, "code");
67
+ const state = getQueryParam(req, "state");
68
+
69
+ if (!code || !state) {
70
+ res.status(400).json({ error: "code and state are required" });
71
+ return;
72
+ }
73
+
74
+ try {
75
+ const tokenResponse = await sdk.exchangeCodeForToken(code, state);
76
+ const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
77
+ await syncUser(userInfo);
78
+ const sessionToken = await sdk.createSessionToken(userInfo.openId!, {
79
+ name: userInfo.name || "",
80
+ expiresInMs: ONE_YEAR_MS,
81
+ });
82
+
83
+ const cookieOptions = getSessionCookieOptions(req);
84
+ res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
85
+
86
+ // Redirect to the frontend URL (Expo web on port 8081)
87
+ // Cookie is set with parent domain so it works across both 3000 and 8081 subdomains
88
+ const frontendUrl =
89
+ process.env.EXPO_WEB_PREVIEW_URL ||
90
+ process.env.EXPO_PACKAGER_PROXY_URL ||
91
+ "http://localhost:8081";
92
+ res.redirect(302, frontendUrl);
93
+ } catch (error) {
94
+ console.error("[OAuth] Callback failed", error);
95
+ res.status(500).json({ error: "OAuth callback failed" });
96
+ }
97
+ });
98
+
99
+ app.get("/api/oauth/mobile", async (req: Request, res: Response) => {
100
+ const code = getQueryParam(req, "code");
101
+ const state = getQueryParam(req, "state");
102
+
103
+ if (!code || !state) {
104
+ res.status(400).json({ error: "code and state are required" });
105
+ return;
106
+ }
107
+
108
+ try {
109
+ const tokenResponse = await sdk.exchangeCodeForToken(code, state);
110
+ const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
111
+ const user = await syncUser(userInfo);
112
+
113
+ const sessionToken = await sdk.createSessionToken(userInfo.openId!, {
114
+ name: userInfo.name || "",
115
+ expiresInMs: ONE_YEAR_MS,
116
+ });
117
+
118
+ const cookieOptions = getSessionCookieOptions(req);
119
+ res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
120
+
121
+ res.json({
122
+ app_session_id: sessionToken,
123
+ user: buildUserResponse(user),
124
+ });
125
+ } catch (error) {
126
+ console.error("[OAuth] Mobile exchange failed", error);
127
+ res.status(500).json({ error: "OAuth mobile exchange failed" });
128
+ }
129
+ });
130
+
131
+ app.post("/api/auth/logout", (req: Request, res: Response) => {
132
+ const cookieOptions = getSessionCookieOptions(req);
133
+ res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
134
+ res.json({ success: true });
135
+ });
136
+
137
+ // Get current authenticated user - works with both cookie (web) and Bearer token (mobile)
138
+ app.get("/api/auth/me", async (req: Request, res: Response) => {
139
+ try {
140
+ const user = await sdk.authenticateRequest(req);
141
+ res.json({ user: buildUserResponse(user) });
142
+ } catch (error) {
143
+ console.error("[Auth] /api/auth/me failed:", error);
144
+ res.status(401).json({ error: "Not authenticated", user: null });
145
+ }
146
+ });
147
+
148
+ // Establish session cookie from Bearer token
149
+ // Used by iframe preview: frontend receives token via postMessage, then calls this endpoint
150
+ // to get a proper Set-Cookie response from the backend (3000-xxx domain)
151
+ app.post("/api/auth/session", async (req: Request, res: Response) => {
152
+ try {
153
+ // Authenticate using Bearer token from Authorization header
154
+ const user = await sdk.authenticateRequest(req);
155
+
156
+ // Get the token from the Authorization header to set as cookie
157
+ const authHeader = req.headers.authorization || req.headers.Authorization;
158
+ if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
159
+ res.status(400).json({ error: "Bearer token required" });
160
+ return;
161
+ }
162
+ const token = authHeader.slice("Bearer ".length).trim();
163
+
164
+ // Set cookie for this domain (3000-xxx)
165
+ const cookieOptions = getSessionCookieOptions(req);
166
+ res.cookie(COOKIE_NAME, token, { ...cookieOptions, maxAge: ONE_YEAR_MS });
167
+
168
+ res.json({ success: true, user: buildUserResponse(user) });
169
+ } catch (error) {
170
+ console.error("[Auth] /api/auth/session failed:", error);
171
+ res.status(401).json({ error: "Invalid token" });
172
+ }
173
+ });
174
+ }
server/_core/sdk.ts ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "../../shared/const.js";
2
+ import { ForbiddenError } from "../../shared/_core/errors.js";
3
+ import axios, { type AxiosInstance } from "axios";
4
+ import { parse as parseCookieHeader } from "cookie";
5
+ import type { Request } from "express";
6
+ import { SignJWT, jwtVerify } from "jose";
7
+ import type { User } from "../../drizzle/schema";
8
+ import * as db from "../db";
9
+ import { ENV } from "./env";
10
+ import type {
11
+ ExchangeTokenRequest,
12
+ ExchangeTokenResponse,
13
+ GetUserInfoResponse,
14
+ GetUserInfoWithJwtRequest,
15
+ GetUserInfoWithJwtResponse,
16
+ } from "./types/manusTypes";
17
+ // Utility function
18
+ const isNonEmptyString = (value: unknown): value is string =>
19
+ typeof value === "string" && value.length > 0;
20
+
21
+ export type SessionPayload = {
22
+ openId: string;
23
+ appId: string;
24
+ name: string;
25
+ };
26
+
27
+ const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
28
+ const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
29
+ const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
30
+
31
+ class OAuthService {
32
+ constructor(private client: ReturnType<typeof axios.create>) {
33
+ console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
34
+ if (!ENV.oAuthServerUrl) {
35
+ console.error(
36
+ "[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable.",
37
+ );
38
+ }
39
+ }
40
+
41
+ private decodeState(state: string): string {
42
+ const redirectUri = atob(state);
43
+ return redirectUri;
44
+ }
45
+
46
+ async getTokenByCode(code: string, state: string): Promise<ExchangeTokenResponse> {
47
+ const payload: ExchangeTokenRequest = {
48
+ clientId: ENV.appId,
49
+ grantType: "authorization_code",
50
+ code,
51
+ redirectUri: this.decodeState(state),
52
+ };
53
+
54
+ const { data } = await this.client.post<ExchangeTokenResponse>(EXCHANGE_TOKEN_PATH, payload);
55
+
56
+ return data;
57
+ }
58
+
59
+ async getUserInfoByToken(token: ExchangeTokenResponse): Promise<GetUserInfoResponse> {
60
+ const { data } = await this.client.post<GetUserInfoResponse>(GET_USER_INFO_PATH, {
61
+ accessToken: token.accessToken,
62
+ });
63
+
64
+ return data;
65
+ }
66
+ }
67
+
68
+ const createOAuthHttpClient = (): AxiosInstance =>
69
+ axios.create({
70
+ baseURL: ENV.oAuthServerUrl,
71
+ timeout: AXIOS_TIMEOUT_MS,
72
+ });
73
+
74
+ class SDKServer {
75
+ private readonly client: AxiosInstance;
76
+ private readonly oauthService: OAuthService;
77
+
78
+ constructor(client: AxiosInstance = createOAuthHttpClient()) {
79
+ this.client = client;
80
+ this.oauthService = new OAuthService(this.client);
81
+ }
82
+
83
+ private deriveLoginMethod(
84
+ platforms: unknown,
85
+ fallback: string | null | undefined,
86
+ ): string | null {
87
+ if (fallback && fallback.length > 0) return fallback;
88
+ if (!Array.isArray(platforms) || platforms.length === 0) return null;
89
+ const set = new Set<string>(platforms.filter((p): p is string => typeof p === "string"));
90
+ if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
91
+ if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
92
+ if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
93
+ if (set.has("REGISTERED_PLATFORM_MICROSOFT") || set.has("REGISTERED_PLATFORM_AZURE"))
94
+ return "microsoft";
95
+ if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
96
+ const first = Array.from(set)[0];
97
+ return first ? first.toLowerCase() : null;
98
+ }
99
+
100
+ /**
101
+ * Exchange OAuth authorization code for access token
102
+ * @example
103
+ * const tokenResponse = await sdk.exchangeCodeForToken(code, state);
104
+ */
105
+ async exchangeCodeForToken(code: string, state: string): Promise<ExchangeTokenResponse> {
106
+ return this.oauthService.getTokenByCode(code, state);
107
+ }
108
+
109
+ /**
110
+ * Get user information using access token
111
+ * @example
112
+ * const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
113
+ */
114
+ async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
115
+ const data = await this.oauthService.getUserInfoByToken({
116
+ accessToken,
117
+ } as ExchangeTokenResponse);
118
+ const loginMethod = this.deriveLoginMethod(
119
+ (data as any)?.platforms,
120
+ (data as any)?.platform ?? data.platform ?? null,
121
+ );
122
+ return {
123
+ ...(data as any),
124
+ platform: loginMethod,
125
+ loginMethod,
126
+ } as GetUserInfoResponse;
127
+ }
128
+
129
+ private parseCookies(cookieHeader: string | undefined) {
130
+ if (!cookieHeader) {
131
+ return new Map<string, string>();
132
+ }
133
+
134
+ const parsed = parseCookieHeader(cookieHeader);
135
+ return new Map(Object.entries(parsed));
136
+ }
137
+
138
+ private getSessionSecret() {
139
+ const secret = ENV.cookieSecret;
140
+ return new TextEncoder().encode(secret);
141
+ }
142
+
143
+ /**
144
+ * Create a session token for a Manus user openId
145
+ * @example
146
+ * const sessionToken = await sdk.createSessionToken(userInfo.openId);
147
+ */
148
+ async createSessionToken(
149
+ openId: string,
150
+ options: { expiresInMs?: number; name?: string } = {},
151
+ ): Promise<string> {
152
+ return this.signSession(
153
+ {
154
+ openId,
155
+ appId: ENV.appId,
156
+ name: options.name || "",
157
+ },
158
+ options,
159
+ );
160
+ }
161
+
162
+ async signSession(
163
+ payload: SessionPayload,
164
+ options: { expiresInMs?: number } = {},
165
+ ): Promise<string> {
166
+ const issuedAt = Date.now();
167
+ const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
168
+ const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
169
+ const secretKey = this.getSessionSecret();
170
+
171
+ return new SignJWT({
172
+ openId: payload.openId,
173
+ appId: payload.appId,
174
+ name: payload.name,
175
+ })
176
+ .setProtectedHeader({ alg: "HS256", typ: "JWT" })
177
+ .setExpirationTime(expirationSeconds)
178
+ .sign(secretKey);
179
+ }
180
+
181
+ async verifySession(
182
+ cookieValue: string | undefined | null,
183
+ ): Promise<{ openId: string; appId: string; name: string } | null> {
184
+ if (!cookieValue) {
185
+ console.warn("[Auth] Missing session cookie");
186
+ return null;
187
+ }
188
+
189
+ try {
190
+ const secretKey = this.getSessionSecret();
191
+ const { payload } = await jwtVerify(cookieValue, secretKey, {
192
+ algorithms: ["HS256"],
193
+ });
194
+ const { openId, appId, name } = payload as Record<string, unknown>;
195
+
196
+ if (!isNonEmptyString(openId) || !isNonEmptyString(appId) || !isNonEmptyString(name)) {
197
+ console.warn("[Auth] Session payload missing required fields");
198
+ return null;
199
+ }
200
+
201
+ return {
202
+ openId,
203
+ appId,
204
+ name,
205
+ };
206
+ } catch (error) {
207
+ console.warn("[Auth] Session verification failed", String(error));
208
+ return null;
209
+ }
210
+ }
211
+
212
+ async getUserInfoWithJwt(jwtToken: string): Promise<GetUserInfoWithJwtResponse> {
213
+ const payload: GetUserInfoWithJwtRequest = {
214
+ jwtToken,
215
+ projectId: ENV.appId,
216
+ };
217
+
218
+ const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
219
+ GET_USER_INFO_WITH_JWT_PATH,
220
+ payload,
221
+ );
222
+
223
+ const loginMethod = this.deriveLoginMethod(
224
+ (data as any)?.platforms,
225
+ (data as any)?.platform ?? data.platform ?? null,
226
+ );
227
+ return {
228
+ ...(data as any),
229
+ platform: loginMethod,
230
+ loginMethod,
231
+ } as GetUserInfoWithJwtResponse;
232
+ }
233
+
234
+ async authenticateRequest(req: Request): Promise<User> {
235
+ // Regular authentication flow
236
+ const authHeader = req.headers.authorization || req.headers.Authorization;
237
+ let token: string | undefined;
238
+ if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
239
+ token = authHeader.slice("Bearer ".length).trim();
240
+ }
241
+
242
+ const cookies = this.parseCookies(req.headers.cookie);
243
+ const sessionCookie = token || cookies.get(COOKIE_NAME);
244
+ const session = await this.verifySession(sessionCookie);
245
+
246
+ if (!session) {
247
+ throw ForbiddenError("Invalid session cookie");
248
+ }
249
+
250
+ const sessionUserId = session.openId;
251
+ const signedInAt = new Date();
252
+ let user = await db.getUserByOpenId(sessionUserId);
253
+
254
+ // If user not in DB, sync from OAuth server automatically
255
+ if (!user) {
256
+ try {
257
+ const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
258
+ await db.upsertUser({
259
+ openId: userInfo.openId,
260
+ name: userInfo.name || null,
261
+ email: userInfo.email ?? null,
262
+ loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
263
+ lastSignedIn: signedInAt,
264
+ });
265
+ user = await db.getUserByOpenId(userInfo.openId);
266
+ } catch (error) {
267
+ console.error("[Auth] Failed to sync user from OAuth:", error);
268
+ throw ForbiddenError("Failed to sync user info");
269
+ }
270
+ }
271
+
272
+ if (!user) {
273
+ throw ForbiddenError("User not found");
274
+ }
275
+
276
+ await db.upsertUser({
277
+ openId: user.openId,
278
+ lastSignedIn: signedInAt,
279
+ });
280
+
281
+ return user;
282
+ }
283
+ }
284
+
285
+ export const sdk = new SDKServer();
server/_core/systemRouter.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { notifyOwner } from "./notification";
3
+ import { adminProcedure, publicProcedure, router } from "./trpc";
4
+
5
+ export const systemRouter = router({
6
+ health: publicProcedure
7
+ .input(
8
+ z.object({
9
+ timestamp: z.number().min(0, "timestamp cannot be negative"),
10
+ }),
11
+ )
12
+ .query(() => ({
13
+ ok: true,
14
+ })),
15
+
16
+ notifyOwner: adminProcedure
17
+ .input(
18
+ z.object({
19
+ title: z.string().min(1, "title is required"),
20
+ content: z.string().min(1, "content is required"),
21
+ }),
22
+ )
23
+ .mutation(async ({ input }) => {
24
+ const delivered = await notifyOwner(input);
25
+ return {
26
+ success: delivered,
27
+ } as const;
28
+ }),
29
+ });
server/_core/trpc.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from "../../shared/const.js";
2
+ import { initTRPC, TRPCError } from "@trpc/server";
3
+ import superjson from "superjson";
4
+ import type { TrpcContext } from "./context";
5
+
6
+ const t = initTRPC.context<TrpcContext>().create({
7
+ transformer: superjson,
8
+ });
9
+
10
+ export const router = t.router;
11
+ export const publicProcedure = t.procedure;
12
+
13
+ const requireUser = t.middleware(async (opts) => {
14
+ const { ctx, next } = opts;
15
+
16
+ if (!ctx.user) {
17
+ throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
18
+ }
19
+
20
+ return next({
21
+ ctx: {
22
+ ...ctx,
23
+ user: ctx.user,
24
+ },
25
+ });
26
+ });
27
+
28
+ export const protectedProcedure = t.procedure.use(requireUser);
29
+
30
+ export const adminProcedure = t.procedure.use(
31
+ t.middleware(async (opts) => {
32
+ const { ctx, next } = opts;
33
+
34
+ if (!ctx.user || ctx.user.role !== "admin") {
35
+ throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
36
+ }
37
+
38
+ return next({
39
+ ctx: {
40
+ ...ctx,
41
+ user: ctx.user,
42
+ },
43
+ });
44
+ }),
45
+ );
server/_core/types/cookie.d.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ declare module "cookie" {
2
+ export function parse(str: string, options?: Record<string, unknown>): Record<string, string>;
3
+ }
server/_core/types/manusTypes.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // WebDev Auth TypeScript types
2
+ // Auto-generated from protobuf definitions
3
+ // Generated on: 2025-09-24T05:57:57.338Z
4
+
5
+ export interface AuthorizeRequest {
6
+ redirectUri: string;
7
+ projectId: string;
8
+ state: string;
9
+ responseType: string;
10
+ scope: string;
11
+ }
12
+
13
+ export interface AuthorizeResponse {
14
+ redirectUrl: string;
15
+ }
16
+
17
+ export interface ExchangeTokenRequest {
18
+ grantType: string;
19
+ code: string;
20
+ refreshToken?: string;
21
+ clientId: string;
22
+ clientSecret?: string;
23
+ redirectUri: string;
24
+ }
25
+
26
+ export interface ExchangeTokenResponse {
27
+ accessToken: string;
28
+ tokenType: string;
29
+ expiresIn: number;
30
+ refreshToken?: string;
31
+ scope: string;
32
+ idToken: string;
33
+ }
34
+
35
+ export interface GetUserInfoRequest {
36
+ accessToken: string;
37
+ }
38
+
39
+ export interface GetUserInfoResponse {
40
+ openId: string;
41
+ projectId: string;
42
+ name: string;
43
+ email?: string | null;
44
+ platform?: string | null;
45
+ loginMethod?: string | null;
46
+ }
47
+
48
+ export interface CanAccessRequest {
49
+ openId: string;
50
+ projectId: string;
51
+ }
52
+
53
+ export interface CanAccessResponse {
54
+ canAccess: boolean;
55
+ }
56
+
57
+ export interface GetUserInfoWithJwtRequest {
58
+ jwtToken: string;
59
+ projectId: string;
60
+ }
61
+
62
+ export interface GetUserInfoWithJwtResponse {
63
+ openId: string;
64
+ projectId: string;
65
+ name: string;
66
+ email?: string | null;
67
+ platform?: string | null;
68
+ loginMethod?: string | null;
69
+ }
server/_core/voiceTranscription.ts ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Voice transcription helper using internal Speech-to-Text service
3
+ *
4
+ * Frontend implementation guide:
5
+ * 1. Capture audio using MediaRecorder API
6
+ * 2. Upload audio to storage (e.g., S3) to get URL
7
+ * 3. Call transcription with the URL
8
+ *
9
+ * Example usage:
10
+ * ```tsx
11
+ * // Frontend component
12
+ * const transcribeMutation = trpc.voice.transcribe.useMutation({
13
+ * onSuccess: (data) => {
14
+ * console.log(data.text); // Full transcription
15
+ * console.log(data.language); // Detected language
16
+ * console.log(data.segments); // Timestamped segments
17
+ * }
18
+ * });
19
+ *
20
+ * // After uploading audio to storage
21
+ * transcribeMutation.mutate({
22
+ * audioUrl: uploadedAudioUrl,
23
+ * language: 'en', // optional
24
+ * prompt: 'Transcribe the meeting' // optional
25
+ * });
26
+ * ```
27
+ */
28
+ import { ENV } from "./env";
29
+
30
+ export type TranscribeOptions = {
31
+ audioUrl: string; // URL to the audio file (e.g., S3 URL)
32
+ language?: string; // Optional: specify language code (e.g., "en", "es", "zh")
33
+ prompt?: string; // Optional: custom prompt for the transcription
34
+ };
35
+
36
+ // Native Whisper API segment format
37
+ export type WhisperSegment = {
38
+ id: number;
39
+ seek: number;
40
+ start: number;
41
+ end: number;
42
+ text: string;
43
+ tokens: number[];
44
+ temperature: number;
45
+ avg_logprob: number;
46
+ compression_ratio: number;
47
+ no_speech_prob: number;
48
+ };
49
+
50
+ // Native Whisper API response format
51
+ export type WhisperResponse = {
52
+ task: "transcribe";
53
+ language: string;
54
+ duration: number;
55
+ text: string;
56
+ segments: WhisperSegment[];
57
+ };
58
+
59
+ export type TranscriptionResponse = WhisperResponse; // Return native Whisper API response directly
60
+
61
+ export type TranscriptionError = {
62
+ error: string;
63
+ code:
64
+ | "FILE_TOO_LARGE"
65
+ | "INVALID_FORMAT"
66
+ | "TRANSCRIPTION_FAILED"
67
+ | "UPLOAD_FAILED"
68
+ | "SERVICE_ERROR";
69
+ details?: string;
70
+ };
71
+
72
+ /**
73
+ * Transcribe audio to text using the internal Speech-to-Text service
74
+ *
75
+ * @param options - Audio data and metadata
76
+ * @returns Transcription result or error
77
+ */
78
+ export async function transcribeAudio(
79
+ options: TranscribeOptions,
80
+ ): Promise<TranscriptionResponse | TranscriptionError> {
81
+ try {
82
+ // Step 1: Validate environment configuration
83
+ if (!ENV.forgeApiUrl) {
84
+ return {
85
+ error: "Voice transcription service is not configured",
86
+ code: "SERVICE_ERROR",
87
+ details: "BUILT_IN_FORGE_API_URL is not set",
88
+ };
89
+ }
90
+ if (!ENV.forgeApiKey) {
91
+ return {
92
+ error: "Voice transcription service authentication is missing",
93
+ code: "SERVICE_ERROR",
94
+ details: "BUILT_IN_FORGE_API_KEY is not set",
95
+ };
96
+ }
97
+
98
+ // Step 2: Download audio from URL
99
+ let audioBuffer: Buffer;
100
+ let mimeType: string;
101
+ try {
102
+ const response = await fetch(options.audioUrl);
103
+ if (!response.ok) {
104
+ return {
105
+ error: "Failed to download audio file",
106
+ code: "INVALID_FORMAT",
107
+ details: `HTTP ${response.status}: ${response.statusText}`,
108
+ };
109
+ }
110
+
111
+ audioBuffer = Buffer.from(await response.arrayBuffer());
112
+ mimeType = response.headers.get("content-type") || "audio/mpeg";
113
+
114
+ // Check file size (16MB limit)
115
+ const sizeMB = audioBuffer.length / (1024 * 1024);
116
+ if (sizeMB > 16) {
117
+ return {
118
+ error: "Audio file exceeds maximum size limit",
119
+ code: "FILE_TOO_LARGE",
120
+ details: `File size is ${sizeMB.toFixed(2)}MB, maximum allowed is 16MB`,
121
+ };
122
+ }
123
+ } catch (error) {
124
+ return {
125
+ error: "Failed to fetch audio file",
126
+ code: "SERVICE_ERROR",
127
+ details: error instanceof Error ? error.message : "Unknown error",
128
+ };
129
+ }
130
+
131
+ // Step 3: Create FormData for multipart upload to Whisper API
132
+ const formData = new FormData();
133
+
134
+ // Create a Blob from the buffer and append to form
135
+ const filename = `audio.${getFileExtension(mimeType)}`;
136
+ const audioBlob = new Blob([new Uint8Array(audioBuffer)], { type: mimeType });
137
+ formData.append("file", audioBlob, filename);
138
+
139
+ formData.append("model", "whisper-1");
140
+ formData.append("response_format", "verbose_json");
141
+
142
+ // Add prompt - use custom prompt if provided, otherwise generate based on language
143
+ const prompt =
144
+ options.prompt ||
145
+ (options.language
146
+ ? `Transcribe the user's voice to text, the user's working language is ${getLanguageName(options.language)}`
147
+ : "Transcribe the user's voice to text");
148
+ formData.append("prompt", prompt);
149
+
150
+ // Step 4: Call the transcription service
151
+ const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`;
152
+
153
+ const fullUrl = new URL("v1/audio/transcriptions", baseUrl).toString();
154
+
155
+ const response = await fetch(fullUrl, {
156
+ method: "POST",
157
+ headers: {
158
+ authorization: `Bearer ${ENV.forgeApiKey}`,
159
+ "Accept-Encoding": "identity",
160
+ },
161
+ body: formData,
162
+ });
163
+
164
+ if (!response.ok) {
165
+ const errorText = await response.text().catch(() => "");
166
+ return {
167
+ error: "Transcription service request failed",
168
+ code: "TRANSCRIPTION_FAILED",
169
+ details: `${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ""}`,
170
+ };
171
+ }
172
+
173
+ // Step 5: Parse and return the transcription result
174
+ const whisperResponse = (await response.json()) as WhisperResponse;
175
+
176
+ // Validate response structure
177
+ if (!whisperResponse.text || typeof whisperResponse.text !== "string") {
178
+ return {
179
+ error: "Invalid transcription response",
180
+ code: "SERVICE_ERROR",
181
+ details: "Transcription service returned an invalid response format",
182
+ };
183
+ }
184
+
185
+ return whisperResponse; // Return native Whisper API response directly
186
+ } catch (error) {
187
+ // Handle unexpected errors
188
+ return {
189
+ error: "Voice transcription failed",
190
+ code: "SERVICE_ERROR",
191
+ details: error instanceof Error ? error.message : "An unexpected error occurred",
192
+ };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Helper function to get file extension from MIME type
198
+ */
199
+ function getFileExtension(mimeType: string): string {
200
+ const mimeToExt: Record<string, string> = {
201
+ "audio/webm": "webm",
202
+ "audio/mp3": "mp3",
203
+ "audio/mpeg": "mp3",
204
+ "audio/wav": "wav",
205
+ "audio/wave": "wav",
206
+ "audio/ogg": "ogg",
207
+ "audio/m4a": "m4a",
208
+ "audio/mp4": "m4a",
209
+ };
210
+
211
+ return mimeToExt[mimeType] || "audio";
212
+ }
213
+
214
+ /**
215
+ * Helper function to get full language name from ISO code
216
+ */
217
+ function getLanguageName(langCode: string): string {
218
+ const langMap: Record<string, string> = {
219
+ en: "English",
220
+ es: "Spanish",
221
+ fr: "French",
222
+ de: "German",
223
+ it: "Italian",
224
+ pt: "Portuguese",
225
+ ru: "Russian",
226
+ ja: "Japanese",
227
+ ko: "Korean",
228
+ zh: "Chinese",
229
+ ar: "Arabic",
230
+ hi: "Hindi",
231
+ nl: "Dutch",
232
+ pl: "Polish",
233
+ tr: "Turkish",
234
+ sv: "Swedish",
235
+ da: "Danish",
236
+ no: "Norwegian",
237
+ fi: "Finnish",
238
+ };
239
+
240
+ return langMap[langCode] || langCode;
241
+ }
242
+
243
+ /**
244
+ * Example tRPC procedure implementation:
245
+ *
246
+ * ```ts
247
+ * // In server/routers.ts
248
+ * import { transcribeAudio } from "./_core/voiceTranscription";
249
+ *
250
+ * export const voiceRouter = router({
251
+ * transcribe: protectedProcedure
252
+ * .input(z.object({
253
+ * audioUrl: z.string(),
254
+ * language: z.string().optional(),
255
+ * prompt: z.string().optional(),
256
+ * }))
257
+ * .mutation(async ({ input, ctx }) => {
258
+ * const result = await transcribeAudio(input);
259
+ *
260
+ * // Check if it's an error
261
+ * if ('error' in result) {
262
+ * throw new TRPCError({
263
+ * code: 'BAD_REQUEST',
264
+ * message: result.error,
265
+ * cause: result,
266
+ * });
267
+ * }
268
+ *
269
+ * // Optionally save transcription to database
270
+ * await db.insert(transcriptions).values({
271
+ * userId: ctx.user.id,
272
+ * text: result.text,
273
+ * duration: result.duration,
274
+ * language: result.language,
275
+ * audioUrl: input.audioUrl,
276
+ * createdAt: new Date(),
277
+ * });
278
+ *
279
+ * return result;
280
+ * }),
281
+ * });
282
+ * ```
283
+ */
server/db.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { eq } from "drizzle-orm";
2
+ import { drizzle } from "drizzle-orm/mysql2";
3
+ import { InsertUser, users } from "../drizzle/schema";
4
+ import { ENV } from "./_core/env";
5
+
6
+ let _db: ReturnType<typeof drizzle> | null = null;
7
+
8
+ // Lazily create the drizzle instance so local tooling can run without a DB.
9
+ export async function getDb() {
10
+ if (!_db && process.env.DATABASE_URL) {
11
+ try {
12
+ _db = drizzle(process.env.DATABASE_URL);
13
+ } catch (error) {
14
+ console.warn("[Database] Failed to connect:", error);
15
+ _db = null;
16
+ }
17
+ }
18
+ return _db;
19
+ }
20
+
21
+ export async function upsertUser(user: InsertUser): Promise<void> {
22
+ if (!user.openId) {
23
+ throw new Error("User openId is required for upsert");
24
+ }
25
+
26
+ const db = await getDb();
27
+ if (!db) {
28
+ console.warn("[Database] Cannot upsert user: database not available");
29
+ return;
30
+ }
31
+
32
+ try {
33
+ const values: InsertUser = {
34
+ openId: user.openId,
35
+ };
36
+ const updateSet: Record<string, unknown> = {};
37
+
38
+ const textFields = ["name", "email", "loginMethod"] as const;
39
+ type TextField = (typeof textFields)[number];
40
+
41
+ const assignNullable = (field: TextField) => {
42
+ const value = user[field];
43
+ if (value === undefined) return;
44
+ const normalized = value ?? null;
45
+ values[field] = normalized;
46
+ updateSet[field] = normalized;
47
+ };
48
+
49
+ textFields.forEach(assignNullable);
50
+
51
+ if (user.lastSignedIn !== undefined) {
52
+ values.lastSignedIn = user.lastSignedIn;
53
+ updateSet.lastSignedIn = user.lastSignedIn;
54
+ }
55
+ if (user.role !== undefined) {
56
+ values.role = user.role;
57
+ updateSet.role = user.role;
58
+ } else if (user.openId === ENV.ownerOpenId) {
59
+ values.role = "admin";
60
+ updateSet.role = "admin";
61
+ }
62
+
63
+ if (!values.lastSignedIn) {
64
+ values.lastSignedIn = new Date();
65
+ }
66
+
67
+ if (Object.keys(updateSet).length === 0) {
68
+ updateSet.lastSignedIn = new Date();
69
+ }
70
+
71
+ await db.insert(users).values(values).onDuplicateKeyUpdate({
72
+ set: updateSet,
73
+ });
74
+ } catch (error) {
75
+ console.error("[Database] Failed to upsert user:", error);
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ export async function getUserByOpenId(openId: string) {
81
+ const db = await getDb();
82
+ if (!db) {
83
+ console.warn("[Database] Cannot get user: database not available");
84
+ return undefined;
85
+ }
86
+
87
+ const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
88
+
89
+ return result.length > 0 ? result[0] : undefined;
90
+ }
91
+
92
+ // TODO: add feature queries here as your schema grows.
server/routers.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { COOKIE_NAME } from "../shared/const.js";
3
+ import { getSessionCookieOptions } from "./_core/cookies";
4
+ import { systemRouter } from "./_core/systemRouter";
5
+ import { publicProcedure, router } from "./_core/trpc";
6
+ import { transcribeAudio } from "./_core/voiceTranscription";
7
+ import { storagePut } from "./storage";
8
+
9
+ export const appRouter = router({
10
+ system: systemRouter,
11
+ auth: router({
12
+ me: publicProcedure.query((opts) => opts.ctx.user),
13
+ logout: publicProcedure.mutation(({ ctx }) => {
14
+ const cookieOptions = getSessionCookieOptions(ctx.req);
15
+ ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
16
+ return { success: true } as const;
17
+ }),
18
+ }),
19
+
20
+ voice: router({
21
+ /**
22
+ * يستقبل ملف صوتي مُرمَّز بـ Base64، يرفعه إلى S3، ثم يُرسله لـ Whisper للتعرف على الكلام العربي
23
+ */
24
+ transcribe: publicProcedure
25
+ .input(
26
+ z.object({
27
+ audioBase64: z.string(), // ملف الصوت مُرمَّز بـ base64
28
+ mimeType: z.string().default("audio/m4a"), // نوع الملف
29
+ language: z.string().default("ar"), // اللغة (ar للعربية)
30
+ })
31
+ )
32
+ .mutation(async ({ input }) => {
33
+ // 1. تحويل base64 إلى Buffer
34
+ const audioBuffer = Buffer.from(input.audioBase64, "base64");
35
+
36
+ // 2. رفع الملف إلى S3 بمفتاح عشوائي
37
+ const suffix = Date.now().toString(36);
38
+ const ext = input.mimeType.split("/")[1]?.split(";")[0] ?? "m4a";
39
+ const fileKey = `voice-recordings/rec-${suffix}.${ext}`;
40
+
41
+ const { url: audioUrl } = await storagePut(
42
+ fileKey,
43
+ audioBuffer,
44
+ input.mimeType
45
+ );
46
+
47
+ // 3. إرسال الرابط لـ Whisper مع تحديد اللغة العربية
48
+ const result = await transcribeAudio({
49
+ audioUrl,
50
+ language: input.language,
51
+ prompt: "هذا تسجيل صوتي باللغة العربية",
52
+ });
53
+
54
+ // التحقق من نوع النتيجة (نجاح أو خطأ)
55
+ if ("error" in result) {
56
+ throw new Error(result.details ?? result.error);
57
+ }
58
+
59
+ return {
60
+ text: result.text ?? "",
61
+ language: result.language ?? input.language,
62
+ segments: result.segments ?? [],
63
+ };
64
+ }),
65
+ }),
66
+ });
67
+
68
+ export type AppRouter = typeof appRouter;
server/storage.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Preconfigured storage helpers for Manus WebDev templates
2
+ // Uses the Biz-provided storage proxy (Authorization: Bearer <token>)
3
+
4
+ import { ENV } from "./_core/env";
5
+
6
+ type StorageConfig = { baseUrl: string; apiKey: string };
7
+
8
+ function getStorageConfig(): StorageConfig {
9
+ const baseUrl = ENV.forgeApiUrl;
10
+ const apiKey = ENV.forgeApiKey;
11
+
12
+ if (!baseUrl || !apiKey) {
13
+ throw new Error(
14
+ "Storage proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY",
15
+ );
16
+ }
17
+
18
+ return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
19
+ }
20
+
21
+ function buildUploadUrl(baseUrl: string, relKey: string): URL {
22
+ const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl));
23
+ url.searchParams.set("path", normalizeKey(relKey));
24
+ return url;
25
+ }
26
+
27
+ async function buildDownloadUrl(baseUrl: string, relKey: string, apiKey: string): Promise<string> {
28
+ const downloadApiUrl = new URL("v1/storage/downloadUrl", ensureTrailingSlash(baseUrl));
29
+ downloadApiUrl.searchParams.set("path", normalizeKey(relKey));
30
+ const response = await fetch(downloadApiUrl, {
31
+ method: "GET",
32
+ headers: buildAuthHeaders(apiKey),
33
+ });
34
+ return (await response.json()).url;
35
+ }
36
+
37
+ function ensureTrailingSlash(value: string): string {
38
+ return value.endsWith("/") ? value : `${value}/`;
39
+ }
40
+
41
+ function normalizeKey(relKey: string): string {
42
+ return relKey.replace(/^\/+/, "");
43
+ }
44
+
45
+ function toFormData(
46
+ data: Buffer | Uint8Array | string,
47
+ contentType: string,
48
+ fileName: string,
49
+ ): FormData {
50
+ const blob =
51
+ typeof data === "string"
52
+ ? new Blob([data], { type: contentType })
53
+ : new Blob([data as any], { type: contentType });
54
+ const form = new FormData();
55
+ form.append("file", blob, fileName || "file");
56
+ return form;
57
+ }
58
+
59
+ function buildAuthHeaders(apiKey: string): HeadersInit {
60
+ return { Authorization: `Bearer ${apiKey}` };
61
+ }
62
+
63
+ export async function storagePut(
64
+ relKey: string,
65
+ data: Buffer | Uint8Array | string,
66
+ contentType = "application/octet-stream",
67
+ ): Promise<{ key: string; url: string }> {
68
+ const { baseUrl, apiKey } = getStorageConfig();
69
+ const key = normalizeKey(relKey);
70
+ const uploadUrl = buildUploadUrl(baseUrl, key);
71
+ const formData = toFormData(data, contentType, key.split("/").pop() ?? key);
72
+ const response = await fetch(uploadUrl, {
73
+ method: "POST",
74
+ headers: buildAuthHeaders(apiKey),
75
+ body: formData,
76
+ });
77
+
78
+ if (!response.ok) {
79
+ const message = await response.text().catch(() => response.statusText);
80
+ throw new Error(
81
+ `Storage upload failed (${response.status} ${response.statusText}): ${message}`,
82
+ );
83
+ }
84
+ const url = (await response.json()).url;
85
+ return { key, url };
86
+ }
87
+
88
+ export async function storageGet(relKey: string): Promise<{ key: string; url: string }> {
89
+ const { baseUrl, apiKey } = getStorageConfig();
90
+ const key = normalizeKey(relKey);
91
+ return {
92
+ key,
93
+ url: await buildDownloadUrl(baseUrl, key, apiKey),
94
+ };
95
+ }
server/voice-ws.ts ADDED
@@ -0,0 +1,810 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * voice-ws.ts — Real-time voice-to-sign WebSocket server
3
+ *
4
+ * Protocol (client → server):
5
+ * { type: "chunk", audio: "<base64>", mimeType: "audio/m4a", chunkId: N }
6
+ * { type: "text", text: "...", chunkId: N } ← legacy: pre-recognized text
7
+ * { type: "stop" }
8
+ *
9
+ * Protocol (server → client):
10
+ * { type: "ready" }
11
+ * { type: "queued", chunkId, queueSize }
12
+ * { type: "partial_transcript", text, chunkId }
13
+ * { type: "final_transcript", text, words, chunkId, rawText }
14
+ * { type: "words", words, chunkId, rawText } ← legacy compat
15
+ * { type: "error", message, chunkId? }
16
+ *
17
+ * Algorithms:
18
+ * ─────────────────────────────────────────────────────────────────────────────
19
+ * 1. SLIDING WINDOW CONTEXT (NLP)
20
+ * Each chunk sent to Whisper includes the last 5 words as prompt context
21
+ * to prevent word splitting across chunk boundaries.
22
+ *
23
+ * 2. MIN-HEAP ORDERED DELIVERY (Data Structure)
24
+ * Results stored in a min-heap sorted by chunkId for ordered delivery
25
+ * even when results arrive out of order.
26
+ *
27
+ * 3. MAXIMUM MUNCH TOKENIZATION (NLP)
28
+ * Extract all known words from a sentence using greedy longest-match-first.
29
+ *
30
+ * 4. PARALLEL PROCESSING + DROP STRATEGY (Performance)
31
+ * Process up to 4 chunks in parallel; drop excess to prevent queue buildup.
32
+ *
33
+ * 5. ARABIC TEXT NORMALIZATION (NLP) — internal only
34
+ * Used only for sign/avatar VOCAB matching. Raw dialect preserved in transcripts.
35
+ *
36
+ * 6. POSITION-BASED DEDUPLICATION
37
+ * Track sent words by position to prevent immediate repeats.
38
+ * ─────────────────────────────────────────────────────────────────────────────
39
+ */
40
+
41
+ import { WebSocketServer, WebSocket } from "ws";
42
+ import type { Server } from "http";
43
+ import { ENV } from "./_core/env";
44
+
45
+ // ─── Message types ───────────────────────────────────────────────────────────
46
+
47
+ interface ChunkMsg {
48
+ type: "chunk";
49
+ audio: string; // base64
50
+ mimeType?: string;
51
+ chunkId: number;
52
+ }
53
+ interface TextMsg {
54
+ type: "text";
55
+ text: string;
56
+ chunkId: number;
57
+ }
58
+ interface StopMsg {
59
+ type: "stop";
60
+ }
61
+ type ClientMessage = ChunkMsg | TextMsg | StopMsg;
62
+
63
+ // ─── Arabic text normalization (internal only — for sign/avatar matching) ────
64
+
65
+ /** Normalize for VOCAB lookup only. Never use on user-facing transcript text. */
66
+ function normalizeAr(s: string): string {
67
+ return s
68
+ .replace(/[\u064B-\u065F\u0670]/g, "") // remove diacritics
69
+ .replace(/[أإآ]/g, "ا") // unify alif
70
+ .replace(/ة/g, "ه") // taa marbuta → haa
71
+ .replace(/ى/g, "ي") // alif maqsura → yaa
72
+ .replace(/[^\u0600-\u06FF\s]/g, " ") // remove non-Arabic
73
+ .replace(/\s+/g, " ")
74
+ .trim();
75
+ }
76
+
77
+ // ─── Vocabulary (normalized → canonical key) ─────────────────────────────────
78
+
79
+ const VOCAB: Record<string, string> = {
80
+ انا: "أنا",
81
+ بحب: "بحب",
82
+ احب: "بحب",
83
+ اهواك: "بحب",
84
+ صباح: "صباح",
85
+ الخير: "الخير",
86
+ خير: "الخير",
87
+ النور: "النور",
88
+ نور: "النور",
89
+ المدرسه: "المدرسة",
90
+ مدرسه: "المدرسة",
91
+ مدرسة: "المدرسة",
92
+ التعليم: "المدرسة",
93
+ الدراسه: "المدرسة",
94
+ الدراسة: "المدرسة",
95
+ عشان: "عشان",
96
+ لان: "عشان",
97
+ لأن: "عشان",
98
+ مرحبا: "مرحبا",
99
+ مرحبه: "مرحبا",
100
+ اهلا: "مرحبا",
101
+ هلا: "مرحبا",
102
+ السلام: "مرحبا",
103
+ اهلن: "مرحبا",
104
+ شكرا: "شكراً",
105
+ شكر: "شكراً",
106
+ شكرًا: "شكراً",
107
+ عفوا: "عفواً",
108
+ عفو: "عفواً",
109
+ مساء: "مساء",
110
+ مساءً: "مساء",
111
+ مساعده: "مساعدة",
112
+ ساعدني: "مساعدة",
113
+ نجده: "مساعدة",
114
+ مساعدة: "مساعدة",
115
+ النجده: "مساعدة",
116
+ نعم: "نعم",
117
+ اه: "نعم",
118
+ اي: "نعم",
119
+ صح: "نعم",
120
+ ايوه: "نعم",
121
+ لا: "لا",
122
+ ماء: "ماء",
123
+ ميه: "ماء",
124
+ مي: "ماء",
125
+ ماي: "ماء",
126
+ ماءً: "ماء",
127
+ طيب: "طيب",
128
+ حسنا: "طيب",
129
+ تمام: "طيب",
130
+ حسنًا: "طيب",
131
+ اوكيه: "طيب",
132
+ سعيد: "سعيد",
133
+ مبسوط: "سعيد",
134
+ فرحان: "سعيد",
135
+ سعيده: "سعيد",
136
+ مبسوطه: "سعيد",
137
+ };
138
+
139
+ /**
140
+ * NLP: Extract all known words from a sentence in order.
141
+ * Algorithm: Maximum Munch on single-word tokens.
142
+ */
143
+ function extractAllWords(rawText: string): string[] {
144
+ const normalized = normalizeAr(rawText);
145
+ const tokens = normalized.split(/\s+/).filter(Boolean);
146
+ const result: string[] = [];
147
+ for (const token of tokens) {
148
+ const canonical = VOCAB[token];
149
+ if (canonical) result.push(canonical);
150
+ }
151
+ return result;
152
+ }
153
+
154
+ // ─── Min-Heap for ordered delivery ───────────────────────────────────────────
155
+
156
+ interface HeapItem {
157
+ chunkId: number;
158
+ words: string[];
159
+ rawText: string;
160
+ }
161
+
162
+ class MinHeap {
163
+ private data: HeapItem[] = [];
164
+ push(item: HeapItem) {
165
+ this.data.push(item);
166
+ this.bubbleUp(this.data.length - 1);
167
+ }
168
+ peek(): HeapItem | undefined {
169
+ return this.data[0];
170
+ }
171
+ pop(): HeapItem | undefined {
172
+ if (!this.data.length) return undefined;
173
+ const top = this.data[0];
174
+ const last = this.data.pop()!;
175
+ if (this.data.length) {
176
+ this.data[0] = last;
177
+ this.sinkDown(0);
178
+ }
179
+ return top;
180
+ }
181
+ get size() {
182
+ return this.data.length;
183
+ }
184
+ private bubbleUp(i: number) {
185
+ while (i > 0) {
186
+ const p = (i - 1) >> 1;
187
+ if (this.data[p].chunkId <= this.data[i].chunkId) break;
188
+ [this.data[p], this.data[i]] = [this.data[i], this.data[p]];
189
+ i = p;
190
+ }
191
+ }
192
+ private sinkDown(i: number) {
193
+ const n = this.data.length;
194
+ while (true) {
195
+ let min = i;
196
+ const l = 2 * i + 1,
197
+ r = 2 * i + 2;
198
+ if (l < n && this.data[l].chunkId < this.data[min].chunkId) min = l;
199
+ if (r < n && this.data[r].chunkId < this.data[min].chunkId) min = r;
200
+ if (min === i) break;
201
+ [this.data[min], this.data[i]] = [this.data[i], this.data[min]];
202
+ i = min;
203
+ }
204
+ }
205
+ }
206
+
207
+ // ─── Sliding Window Context ──────────────────────────────────────────────────
208
+
209
+ class SlidingContext {
210
+ private lastRawWords: string[] = [];
211
+
212
+ getPrompt(): string {
213
+ // Whisper's initial_prompt is designed for *prior speech context only* — NOT
214
+ // instructions. Passing Arabic instructions causes Whisper to echo their
215
+ // vocabulary (e.g. "موسيقى") as hallucinations. Only pass the last spoken
216
+ // words so Whisper maintains continuity across chunk boundaries.
217
+ if (!this.lastRawWords.length) return "";
218
+ return this.lastRawWords.slice(-5).join(" ");
219
+ }
220
+
221
+ /** Feed raw (un-normalized) Whisper output to preserve dialect in context */
222
+ update(rawText: string) {
223
+ const words = rawText.trim().split(/\s+/).filter(Boolean);
224
+ this.lastRawWords = [...this.lastRawWords, ...words].slice(-10);
225
+ }
226
+
227
+ reset() {
228
+ this.lastRawWords = [];
229
+ }
230
+ }
231
+
232
+ // ─── Voice Session ───────────────────────────────────────────────────────────
233
+
234
+ class VoiceSession {
235
+ private heap = new MinHeap();
236
+ private nextExpected = 0;
237
+ private processing = 0;
238
+ private readonly MAX_CONCURRENT = 4;
239
+ private backlog: {
240
+ chunkId: number;
241
+ audioBase64: string;
242
+ mimeType: string;
243
+ ext: string;
244
+ }[] = [];
245
+ private context = new SlidingContext();
246
+ private allSentWords: string[] = [];
247
+ private totalEnqueued = 0;
248
+ /** How many consecutive chunks came back empty (silence/hallucination filtered) */
249
+ private consecutiveSilenceCount = 0;
250
+
251
+ constructor(
252
+ private ws: WebSocket,
253
+ private sendMsg: (msg: Record<string, any>) => void,
254
+ ) {}
255
+
256
+ enqueue(chunk: {
257
+ chunkId: number;
258
+ audioBase64: string;
259
+ mimeType: string;
260
+ ext: string;
261
+ }) {
262
+ this.totalEnqueued++;
263
+ this.backlog.push(chunk);
264
+ this.sendMsg({
265
+ type: "queued",
266
+ chunkId: chunk.chunkId,
267
+ queueSize: this.processing + this.backlog.length,
268
+ });
269
+
270
+ this.drainBacklog();
271
+ }
272
+
273
+ private drainBacklog() {
274
+ while (this.processing < this.MAX_CONCURRENT && this.backlog.length > 0) {
275
+ const chunk = this.backlog.shift()!;
276
+ this.processing++;
277
+ const prompt = this.context.getPrompt();
278
+ this.processChunk(chunk, prompt)
279
+ .catch((err) =>
280
+ console.error(`[voice-ws] chunk ${chunk.chunkId}:`, err.message),
281
+ )
282
+ .finally(() => {
283
+ this.processing--;
284
+ this.drainBacklog();
285
+ });
286
+ }
287
+ }
288
+
289
+ private async processChunk(
290
+ chunk: {
291
+ chunkId: number;
292
+ audioBase64: string;
293
+ mimeType: string;
294
+ ext: string;
295
+ },
296
+ prompt: string,
297
+ ) {
298
+ let text: string;
299
+ try {
300
+ text = await transcribeWithWhisper(
301
+ chunk.audioBase64,
302
+ chunk.ext,
303
+ chunk.mimeType,
304
+ prompt,
305
+ );
306
+ } catch (err: any) {
307
+ console.error(
308
+ `[voice-ws] ❌ transcription failed for chunk ${chunk.chunkId}:`,
309
+ err.message,
310
+ );
311
+ this.sendMsg({
312
+ type: "error",
313
+ message: `Transcription failed: ${err.message}`,
314
+ chunkId: chunk.chunkId,
315
+ });
316
+ // Still add to heap with empty text so ordering isn't broken
317
+ text = "";
318
+ }
319
+
320
+ // Only update context on real speech.
321
+ // On silence/hallucination (text === ""), increment the silence streak.
322
+ // After 2+ consecutive silent chunks reset the context so stale speech
323
+ // content stops "anchoring" Whisper and pulling it toward hallucinations.
324
+ if (text) {
325
+ this.consecutiveSilenceCount = 0;
326
+ this.context.update(text);
327
+ } else {
328
+ this.consecutiveSilenceCount++;
329
+ if (this.consecutiveSilenceCount >= 1) {
330
+ this.context.reset();
331
+ console.log("[voice-ws] 🔄 context reset — silence detected");
332
+ }
333
+ }
334
+
335
+ // Send partial transcript immediately (before word extraction + ordering)
336
+ if (text.trim()) {
337
+ this.sendMsg({
338
+ type: "partial_transcript",
339
+ text: text.trim(),
340
+ chunkId: chunk.chunkId,
341
+ });
342
+ }
343
+
344
+ const words = extractAllWords(text);
345
+ this.heap.push({ chunkId: chunk.chunkId, words, rawText: text });
346
+ this.flushOrdered();
347
+ }
348
+
349
+ private flushOrdered() {
350
+ while (
351
+ this.heap.size > 0 &&
352
+ this.heap.peek()!.chunkId === this.nextExpected
353
+ ) {
354
+ const item = this.heap.pop()!;
355
+ this.nextExpected++;
356
+
357
+ // Deduplicate words by position
358
+ const newWords: string[] = [];
359
+ if (item.words.length > 0) {
360
+ for (const w of item.words) {
361
+ if (this.shouldSend(w)) {
362
+ newWords.push(w);
363
+ this.allSentWords.push(w);
364
+ }
365
+ }
366
+ }
367
+
368
+ // Send final_transcript with both text and matched words
369
+ this.sendMsg({
370
+ type: "final_transcript",
371
+ text: item.rawText.trim(),
372
+ words: newWords,
373
+ chunkId: item.chunkId,
374
+ rawText: item.rawText.trim(),
375
+ });
376
+
377
+ // Also send legacy "words" message for backward compatibility
378
+ if (newWords.length > 0) {
379
+ this.sendMsg({
380
+ type: "words",
381
+ words: newWords,
382
+ chunkId: item.chunkId,
383
+ rawText: item.rawText,
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Should we send this word?
391
+ * Yes if: never sent before, or 8+ other words have been sent since.
392
+ */
393
+ private shouldSend(word: string): boolean {
394
+ const lastIdx = this.allSentWords.lastIndexOf(word);
395
+ if (lastIdx === -1) return true;
396
+ return this.allSentWords.length - lastIdx > 8;
397
+ }
398
+
399
+ /** Process pre-recognized text (from Web Speech API) — no Whisper needed */
400
+ processText(chunkId: number, rawText: string) {
401
+ if (!rawText.trim()) return;
402
+ this.totalEnqueued++;
403
+ this.sendMsg({
404
+ type: "queued",
405
+ chunkId,
406
+ queueSize: this.processing,
407
+ });
408
+
409
+ const words = extractAllWords(rawText);
410
+ this.context.update(rawText);
411
+ this.heap.push({ chunkId, words, rawText });
412
+ this.flushOrdered();
413
+ }
414
+
415
+ reset() {
416
+ this.heap = new MinHeap();
417
+ this.nextExpected = 0;
418
+ this.processing = 0;
419
+ this.context.reset();
420
+ this.allSentWords = [];
421
+ this.totalEnqueued = 0;
422
+ this.consecutiveSilenceCount = 0;
423
+ }
424
+ }
425
+
426
+ // ─── Whisper API ─────────────────────────────────────────────────────────────
427
+
428
+ // ─── Silence / hallucination detection ───────────────────────────────────────
429
+
430
+ /** no_speech_prob threshold: above this → treat chunk as silence.
431
+ * 0.6 is well-calibrated for Arabic speech; 0.35 was too aggressive and
432
+ * caused real speech with low energy to be silently dropped. */
433
+ const NO_SPEECH_THRESHOLD = 0.6;
434
+
435
+ /** avg_logprob threshold: below this → model is guessing, not transcribing.
436
+ * -0.8 avoids over-filtering dialect Arabic which naturally scores lower. */
437
+ const LOW_CONFIDENCE_LOGPROB = -0.8;
438
+
439
+ /** compression_ratio threshold: above this → repetitive/looping hallucination */
440
+ const HIGH_COMPRESSION_RATIO = 2.4;
441
+
442
+ /**
443
+ * Punctuation-only / symbol-only outputs that carry no speech content.
444
+ */
445
+ const HALLUCINATION_RE = /^[\s.،؟!…\-–—\u200B-\u200F♪♫♬]+$/;
446
+
447
+ /**
448
+ * Ghost phrases Whisper hallucinates from YouTube/podcast training data.
449
+ * These are STRIPPED from transcripts (not used to drop the whole chunk),
450
+ * so real speech appearing in the same chunk is preserved.
451
+ *
452
+ * Order matters: longer/more-specific phrases must come before their substrings
453
+ * so the longer form is stripped first.
454
+ */
455
+ const GHOST_PHRASES: string[] = [
456
+ // ── Arabic — longer phrases first ────────────────────────────────────────
457
+ "شكراً على المشاهدة",
458
+ "شكرا على المشاهدة",
459
+ "شكراً على المتابعة",
460
+ "شكرا على المتابعة",
461
+ "للمزيد من الفيديوهات",
462
+ "للمزيد من المحتوى",
463
+ "لا تنسى الاشتراك",
464
+ "لا تنسوا الاشتراك",
465
+ "اشترك معنا",
466
+ "اللهم صل على محمد",
467
+ "صلى الله عليه وسلم",
468
+ "بسم الله الرحمن الرحيم",
469
+ "الحمد لله رب العالمين",
470
+ // ── Arabic — single-word ghosts ───────────────────────────────────────────
471
+ "اشتركوا", // subscribe (plural) — before "اشترك"
472
+ "أشترك", // subscribe (hamza)
473
+ "اشترك", // subscribe
474
+ "الحمد لله",
475
+ "سبحان الله",
476
+ "موسيقى", // music — most common Arabic hallucination on silence
477
+ "موسيقا",
478
+ // ── English (training bleed) ─────────────────────────────────────────────
479
+ "thanks for watching",
480
+ "thank you for watching",
481
+ "please subscribe",
482
+ "see you next time",
483
+ "background music",
484
+ "[applause]",
485
+ "[laughter]",
486
+ "[silence]",
487
+ "[inaudible]",
488
+ "[music]",
489
+ "subscribe",
490
+ "music",
491
+ ];
492
+
493
+ /**
494
+ * Strip every ghost phrase from `text`, normalise whitespace, and return
495
+ * the cleaned string. Returns "" if nothing real remains.
496
+ *
497
+ * Stripping (not dropping) preserves real speech that appeared in the same
498
+ * chunk as a hallucination — the previous approach discarded whole chunks.
499
+ */
500
+ function stripGhostPhrases(text: string): string {
501
+ let result = text.trim();
502
+ for (const ghost of GHOST_PHRASES) {
503
+ // Arabic has no case, but toLowerCase is harmless and handles English ghosts.
504
+ const re = new RegExp(ghost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
505
+ const before = result;
506
+ result = result
507
+ .replace(re, " ")
508
+ .replace(/\s{2,}/g, " ")
509
+ .trim();
510
+ if (result !== before) {
511
+ console.log(`[voice-ws] ✂️ stripped ghost "${ghost}" → "${result}"`);
512
+ }
513
+ }
514
+ if (!result || HALLUCINATION_RE.test(result)) return "";
515
+ return result;
516
+ }
517
+
518
+ /**
519
+ * Segment-level stats check: returns true only when the ENTIRE chunk has
520
+ * no real speech (high no_speech_prob, very low logprob, or looping output).
521
+ */
522
+ function isChunkSilence(
523
+ segments:
524
+ | {
525
+ no_speech_prob?: number;
526
+ avg_logprob?: number;
527
+ compression_ratio?: number;
528
+ }[]
529
+ | undefined,
530
+ ): boolean {
531
+ if (!segments?.length) return false;
532
+ const n = segments.length;
533
+
534
+ const avgNoSpeech =
535
+ segments.reduce((s, seg) => s + (seg.no_speech_prob ?? 0), 0) / n;
536
+ if (avgNoSpeech > NO_SPEECH_THRESHOLD) {
537
+ console.log(
538
+ `[voice-ws] 🤫 silence (no_speech_prob=${avgNoSpeech.toFixed(2)})`,
539
+ );
540
+ return true;
541
+ }
542
+
543
+ const avgLogprob =
544
+ segments.reduce((s, seg) => s + (seg.avg_logprob ?? 0), 0) / n;
545
+ if (avgLogprob < LOW_CONFIDENCE_LOGPROB) {
546
+ console.log(
547
+ `[voice-ws] 🎲 low confidence (avg_logprob=${avgLogprob.toFixed(2)})`,
548
+ );
549
+ return true;
550
+ }
551
+
552
+ const avgCompression =
553
+ segments.reduce((s, seg) => s + (seg.compression_ratio ?? 0), 0) / n;
554
+ if (avgCompression > HIGH_COMPRESSION_RATIO) {
555
+ console.log(
556
+ `[voice-ws] 🔁 repetitive (compression_ratio=${avgCompression.toFixed(2)})`,
557
+ );
558
+ return true;
559
+ }
560
+
561
+ return false;
562
+ }
563
+
564
+ /**
565
+ * Master transcript cleaner.
566
+ * 1. Reject pure punctuation/symbol outputs.
567
+ * 2. Strip known ghost phrases (preserves real speech in the same chunk).
568
+ * 3. Drop the chunk only if segment stats confirm total silence.
569
+ *
570
+ * Returns cleaned text, or "" if nothing real remains.
571
+ */
572
+ function cleanTranscript(
573
+ rawText: string,
574
+ segments:
575
+ | {
576
+ no_speech_prob?: number;
577
+ avg_logprob?: number;
578
+ compression_ratio?: number;
579
+ }[]
580
+ | undefined,
581
+ ): string {
582
+ // 1. Pure punctuation / symbols
583
+ if (!rawText.trim() || HALLUCINATION_RE.test(rawText.trim())) return "";
584
+
585
+ // 2. Strip ghost phrases — may return "" if nothing real is left
586
+ const cleaned = stripGhostPhrases(rawText);
587
+ if (!cleaned) return "";
588
+
589
+ // 3. Segment-level silence check
590
+ if (isChunkSilence(segments)) return "";
591
+
592
+ return cleaned;
593
+ }
594
+
595
+ // ─── Whisper API ─────────────────────────────────────────────────────────────
596
+
597
+ async function transcribeWithWhisper(
598
+ audioBase64: string,
599
+ ext: string,
600
+ mimeType: string,
601
+ prompt: string,
602
+ ): Promise<string> {
603
+ if (!ENV.forgeApiUrl || !ENV.forgeApiKey) {
604
+ throw new Error(
605
+ "STT API not configured. Set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY in .env " +
606
+ "(supports OpenAI, Groq, or any OpenAI-compatible Whisper API)",
607
+ );
608
+ }
609
+
610
+ const audioBuffer = Buffer.from(audioBase64, "base64");
611
+ if (audioBuffer.length < 500) {
612
+ console.log("[voice-ws] ⚠️ chunk too small (silence), skipping");
613
+ return "";
614
+ }
615
+
616
+ const mimeMap: Record<string, string> = {
617
+ m4a: "audio/m4a",
618
+ mp4: "audio/mp4",
619
+ webm: "audio/webm",
620
+ wav: "audio/wav",
621
+ mp3: "audio/mpeg",
622
+ ogg: "audio/ogg",
623
+ };
624
+ const resolvedMime = mimeMap[ext] ?? mimeType ?? "audio/m4a";
625
+ const blob = new Blob([audioBuffer], { type: resolvedMime });
626
+
627
+ const models = ["whisper-large-v3-turbo", "whisper-large-v3", "whisper-1"];
628
+ for (const model of models) {
629
+ try {
630
+ const fd = new FormData();
631
+ fd.append("file", blob, `audio.${ext}`);
632
+ fd.append("model", model);
633
+ fd.append("language", "ar");
634
+ // verbose_json gives us no_speech_prob per segment for silence detection
635
+ fd.append("response_format", "verbose_json");
636
+ fd.append("prompt", prompt);
637
+ fd.append("temperature", "0"); // 0 = deterministic, no guessing
638
+
639
+ const baseUrl = ENV.forgeApiUrl.endsWith("/")
640
+ ? ENV.forgeApiUrl
641
+ : `${ENV.forgeApiUrl}/`;
642
+ const endpoint = new URL("v1/audio/transcriptions", baseUrl).toString();
643
+
644
+ console.log(
645
+ `[voice-ws] 🔊 transcribing with ${model} (${Math.round(audioBuffer.length / 1024)}KB)`,
646
+ );
647
+
648
+ const res = await fetch(endpoint, {
649
+ method: "POST",
650
+ headers: {
651
+ authorization: `Bearer ${ENV.forgeApiKey}`,
652
+ "Accept-Encoding": "identity",
653
+ },
654
+ body: fd,
655
+ signal: AbortSignal.timeout(10000),
656
+ });
657
+
658
+ if (!res.ok) {
659
+ const errText = await res.text();
660
+ if (
661
+ res.status === 404 ||
662
+ errText.includes("model") ||
663
+ errText.includes("not found")
664
+ ) {
665
+ console.warn(`[voice-ws] model ${model} not available, trying next`);
666
+ continue;
667
+ }
668
+ throw new Error(`Whisper ${res.status}: ${errText}`);
669
+ }
670
+
671
+ const data = (await res.json()) as {
672
+ text?: string;
673
+ segments?: {
674
+ no_speech_prob?: number;
675
+ avg_logprob?: number;
676
+ compression_ratio?: number;
677
+ text?: string;
678
+ }[];
679
+ };
680
+ const raw = data.text?.trim() ?? "";
681
+ console.log(`[voice-ws] ✅ ${model} → "${raw}"`);
682
+
683
+ const text = cleanTranscript(raw, data.segments);
684
+ if (text !== raw) {
685
+ console.log(`[voice-ws] 🧹 cleaned → "${text}"`);
686
+ }
687
+ return text;
688
+ } catch (err: any) {
689
+ const cause = err.cause?.message ?? err.cause?.code ?? "";
690
+ if (
691
+ err.name === "TimeoutError" ||
692
+ err.message?.includes("not available")
693
+ ) {
694
+ console.warn(`[voice-ws] ${model} timeout/unavailable, trying next`);
695
+ continue;
696
+ }
697
+ // "fetch failed" is a generic network error — log the root cause for debugging
698
+ if (err.message === "fetch failed") {
699
+ console.error(
700
+ `[voice-ws] ❌ fetch failed for ${model} — cause: ${cause || "unknown"}`,
701
+ );
702
+ throw new Error(`fetch failed (${cause || "network error"})`);
703
+ }
704
+ throw err;
705
+ }
706
+ }
707
+ throw new Error("All Whisper models unavailable");
708
+ }
709
+
710
+ // ─── WebSocket Server ────────────────────────────────────────────────────────
711
+
712
+ function sendJson(ws: WebSocket, msg: Record<string, any>) {
713
+ if (ws.readyState === WebSocket.OPEN) {
714
+ ws.send(JSON.stringify(msg));
715
+ }
716
+ }
717
+
718
+ export function registerVoiceWebSocket(httpServer: Server) {
719
+ const wss = new WebSocketServer({
720
+ server: httpServer,
721
+ path: "/ws/voice",
722
+ perMessageDeflate: false,
723
+ });
724
+
725
+ // Log STT configuration on startup
726
+ if (ENV.forgeApiUrl && ENV.forgeApiKey) {
727
+ console.log(
728
+ `[voice-ws] ✅ STT API configured: ${ENV.forgeApiUrl} (server-side Whisper)`,
729
+ );
730
+ } else {
731
+ console.warn(
732
+ "[voice-ws] ⚠️ STT API NOT configured! Set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY in .env",
733
+ );
734
+ console.warn(
735
+ "[voice-ws] Free option: Groq API → https://console.groq.com (whisper-large-v3-turbo)",
736
+ );
737
+ console.warn(
738
+ "[voice-ws] Set BUILT_IN_FORGE_API_URL=https://api.groq.com/openai",
739
+ );
740
+ console.warn("[voice-ws] Set BUILT_IN_FORGE_API_KEY=gsk_...");
741
+ }
742
+
743
+ wss.on("connection", (ws) => {
744
+ console.log("[voice-ws] 🔌 client connected");
745
+
746
+ const send = (msg: Record<string, any>) => sendJson(ws, msg);
747
+ const session = new VoiceSession(ws, send);
748
+
749
+ send({ type: "ready" });
750
+
751
+ ws.on("message", (raw) => {
752
+ let msg: ClientMessage;
753
+ try {
754
+ msg = JSON.parse(raw.toString());
755
+ } catch {
756
+ return;
757
+ }
758
+
759
+ console.log(
760
+ `[voice-ws] ← ${msg.type}${(msg as any).chunkId != null ? ` #${(msg as any).chunkId}` : ""}`,
761
+ );
762
+
763
+ if (msg.type === "stop") {
764
+ session.reset();
765
+ return;
766
+ }
767
+
768
+ // Pre-recognized text (from Web Speech API or legacy clients)
769
+ if (msg.type === "text" && msg.text) {
770
+ session.processText(msg.chunkId, msg.text);
771
+ return;
772
+ }
773
+
774
+ // Raw audio chunk — transcribe with Whisper
775
+ if (msg.type === "chunk" && msg.audio) {
776
+ // Check STT config before accepting chunks
777
+ if (!ENV.forgeApiUrl || !ENV.forgeApiKey) {
778
+ send({
779
+ type: "error",
780
+ message:
781
+ "Server STT not configured. Set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY.",
782
+ chunkId: msg.chunkId,
783
+ });
784
+ return;
785
+ }
786
+
787
+ const mimeType = msg.mimeType ?? "audio/m4a";
788
+ const ext = mimeType.split("/")[1]?.split(";")[0] ?? "m4a";
789
+ session.enqueue({
790
+ chunkId: msg.chunkId,
791
+ audioBase64: msg.audio,
792
+ mimeType,
793
+ ext,
794
+ });
795
+ }
796
+ });
797
+
798
+ ws.on("close", () => {
799
+ console.log("[voice-ws] 🔌 client disconnected");
800
+ session.reset();
801
+ });
802
+ ws.on("error", (err) => {
803
+ console.error("[voice-ws] ❌ socket error:", err);
804
+ });
805
+ });
806
+
807
+ console.log(
808
+ "[voice-ws] ✅ WebSocket registered at /ws/voice (server-side STT pipeline)",
809
+ );
810
+ }
shared/_core/errors.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Base HTTP error class with status code.
3
+ * Throw this from route handlers to send specific HTTP errors.
4
+ */
5
+ export class HttpError extends Error {
6
+ constructor(
7
+ public statusCode: number,
8
+ message: string,
9
+ ) {
10
+ super(message);
11
+ this.name = "HttpError";
12
+ }
13
+ }
14
+
15
+ // Convenience constructors
16
+ export const BadRequestError = (msg: string) => new HttpError(400, msg);
17
+ export const UnauthorizedError = (msg: string) => new HttpError(401, msg);
18
+ export const ForbiddenError = (msg: string) => new HttpError(403, msg);
19
+ export const NotFoundError = (msg: string) => new HttpError(404, msg);
shared/const.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export const COOKIE_NAME = "app_session_id";
2
+ export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
3
+ export const AXIOS_TIMEOUT_MS = 30_000;
4
+ export const UNAUTHED_ERR_MSG = "Please login (10001)";
5
+ export const NOT_ADMIN_ERR_MSG = "You do not have required permission (10002)";
shared/types.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Unified type exports
3
+ * Import shared types from this single entry point.
4
+ */
5
+
6
+ export type * from "../drizzle/schema";
7
+ export * from "./_core/errors";
tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "types": [
6
+ "node",
7
+ "nativewind/types"
8
+ ],
9
+ "paths": {
10
+ "@/*": [
11
+ "./*"
12
+ ],
13
+ "@shared/*": [
14
+ "./shared/*"
15
+ ]
16
+ }
17
+ },
18
+ "include": [
19
+ "**/*.ts",
20
+ "**/*.tsx",
21
+ ".expo/types/**/*.ts",
22
+ "expo-env.d.ts",
23
+ "nativewind-env.d.ts"
24
+ ],
25
+ "exclude": [
26
+ "node_modules",
27
+ "dist"
28
+ ]
29
+ }