Commit ·
cd2a4c2
0
Parent(s):
Deploy Taqarub Node API server
Browse files- Dockerfile +29 -0
- README.md +10 -0
- drizzle.config.ts +15 -0
- drizzle/0000_elite_eternals.sql +13 -0
- drizzle/meta/0000_snapshot.json +106 -0
- drizzle/meta/_journal.json +13 -0
- drizzle/migrations/.gitkeep +0 -0
- drizzle/relations.ts +1 -0
- drizzle/schema.ts +28 -0
- package.json +108 -0
- pnpm-lock.yaml +0 -0
- server/README.md +1235 -0
- server/_core/context.ts +26 -0
- server/_core/cookies.ts +60 -0
- server/_core/dataApi.ts +64 -0
- server/_core/env.ts +10 -0
- server/_core/imageGeneration.ts +81 -0
- server/_core/index.ts +97 -0
- server/_core/llm.ts +314 -0
- server/_core/notification.ts +107 -0
- server/_core/oauth.ts +174 -0
- server/_core/sdk.ts +285 -0
- server/_core/systemRouter.ts +29 -0
- server/_core/trpc.ts +45 -0
- server/_core/types/cookie.d.ts +3 -0
- server/_core/types/manusTypes.ts +69 -0
- server/_core/voiceTranscription.ts +283 -0
- server/db.ts +92 -0
- server/routers.ts +68 -0
- server/storage.ts +95 -0
- server/voice-ws.ts +810 -0
- shared/_core/errors.ts +19 -0
- shared/const.ts +5 -0
- shared/types.ts +7 -0
- tsconfig.json +29 -0
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 |
+
}
|