diff --git a/.env.example b/.env.example index b78105e49..115e448f7 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,11 @@ PUBLIC_PROVISIONER_SHARED_SECRET="your-provisioner-shared-secret" PUBLIC_ESIGNER_BASE_URL="http://localhost:3004" PUBLIC_FILE_MANAGER_BASE_URL="http://localhost:3005" PUBLIC_PROFILE_EDITOR_BASE_URL=http://localhost:3007 +PROFILE_EDITOR_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/profile_editor +PROFILE_EDITOR_JWT_SECRET="secret" +# web3-adapter SQLite id-map directory (localId <-> eVault globalId) +PROFILE_EDITOR_MAPPING_DB_PATH="/path/to/profile-editor/mapping/db" +# PROFILE_EDITOR_API_PORT=3007 # optional, defaults to 3007 DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" @@ -140,6 +145,13 @@ AWARENESS_PUBLIC_URL="http://localhost:4100" AWARENESS_INGEST_SECRET="replace-with-a-strong-secret" # Where evault-core forwards every awareness packet AWARENESS_SERVICE_URL="http://localhost:4100" +# Consumer API key (aaas_...) the profile-editor backend uses to register its +# webhook subscription and poll AaaS. Issued from the AaaS portal after approval. +AWARENESS_API_KEY="" +# Public URL AaaS should POST awareness packets to. Override for local dev +# against prod AaaS — set it to your Cloudflare tunnel, e.g. +# https://xyz.trycloudflare.com/api/webhook. Defaults to {base}/api/webhook. +AWARENESS_WEBHOOK_URL="" # Comma-separated eNames allowed to act as AaaS portal admins AAAS_ADMIN_ENAMES="" diff --git a/platforms/profile-editor/api/nodemon.json b/platforms/profile-editor/api/nodemon.json new file mode 100644 index 000000000..cc8c3486b --- /dev/null +++ b/platforms/profile-editor/api/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts,json", + "ignore": ["src/**/*.spec.ts"], + "exec": "ts-node src/index.ts" +} diff --git a/platforms/profile-editor/api/package.json b/platforms/profile-editor/api/package.json index 2320e88a5..69125dd8c 100644 --- a/platforms/profile-editor/api/package.json +++ b/platforms/profile-editor/api/package.json @@ -1,16 +1,16 @@ { "name": "profile-editor-api", "version": "1.0.0", - "description": "Profile Editor API for the w3ds ecosystem", + "description": "Profile Editor API for the w3ds ecosystem (AaaS-based)", "main": "src/index.ts", "scripts": { "start": "ts-node src/index.ts", "dev": "nodemon --exec ts-node src/index.ts", - "build": "tsc && cp -r src/web3adapter/mappings dist/web3adapter/", + "build": "tsc", "typeorm": "typeorm-ts-node-commonjs", - "migration:generate": "bash -c 'read -p \"Migration name: \" name && npx typeorm-ts-node-commonjs migration:generate src/database/migrations/$name -d src/database/data-source.ts'", - "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", - "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts" + "migration:generate": "bash -c 'read -p \"Migration name: \" name && npx typeorm-ts-node-commonjs migration:generate src/migrations/$name -d src/db.ts'", + "migration:run": "npm run typeorm migration:run -- -d src/db.ts", + "migration:revert": "npm run typeorm migration:revert -- -d src/db.ts" }, "dependencies": { "@metastate-foundation/auth": "workspace:*", @@ -18,25 +18,19 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", - "form-data": "^4.0.5", - "graphql-request": "^6.1.0", - "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "pg": "^8.20.0", "reflect-metadata": "^0.2.2", - "signature-validator": "workspace:*", - "web3-adapter": "workspace:*", "typeorm": "^0.3.28", - "uuid": "^9.0.1" + "web3-adapter": "workspace:*", + "zod": "^3.23.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^20.11.24", "@types/pg": "^8.18.0", - "@types/uuid": "^9.0.8", "nodemon": "^3.0.3", "ts-node": "^10.9.2", "typescript": "^5.3.3" diff --git a/platforms/profile-editor/api/src/aaas.ts b/platforms/profile-editor/api/src/aaas.ts new file mode 100644 index 000000000..bbef742bd --- /dev/null +++ b/platforms/profile-editor/api/src/aaas.ts @@ -0,0 +1,55 @@ +import axios from "axios"; +import { env } from "./env"; + +const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +const PROFESSIONAL_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440009"; + +/** + * Registers our `/api/webhook` as an AaaS subscription for the user and + * professional-profile ontologies (idempotent — skips if one already targets + * us). Logs and continues on failure so a down AaaS never blocks startup. + */ +export async function registerSubscriptionOnStartup(): Promise { + if (!env.awarenessApiKey) { + console.warn( + "[aaas] AWARENESS_API_KEY not set — skipping subscription registration", + ); + return; + } + + const targetUrl = + env.awarenessWebhookUrl || + `${env.baseUrl.replace(/\/$/, "")}/api/webhook`; + const headers = { Authorization: `Bearer ${env.awarenessApiKey}` }; + const base = env.awarenessServiceUrl.replace(/\/$/, ""); + + try { + const { data } = await axios.get<{ + subscriptions: Array<{ targetUrl: string }>; + }>(`${base}/api/subscriptions`, { headers, timeout: 10000 }); + + if (data.subscriptions?.some((s) => s.targetUrl === targetUrl)) { + console.log("[aaas] subscription already registered"); + return; + } + + await axios.post( + `${base}/api/subscriptions`, + { + targetUrl, + ontologyFilter: [USER_ONTOLOGY, PROFESSIONAL_PROFILE_ONTOLOGY], + evaultFilter: [], + }, + { headers, timeout: 10000 }, + ); + console.log(`[aaas] subscription registered -> ${targetUrl}`); + } catch (error) { + const message = axios.isAxiosError(error) + ? (error.response?.data?.error ?? error.message) + : (error as Error).message; + console.error( + "[aaas] subscription registration failed (continuing):", + message, + ); + } +} diff --git a/platforms/profile-editor/api/src/controllers/AuthController.ts b/platforms/profile-editor/api/src/controllers/AuthController.ts deleted file mode 100644 index 47b5bd56f..000000000 --- a/platforms/profile-editor/api/src/controllers/AuthController.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Request, Response } from "express"; -import { EventEmitter } from "events"; -import { - buildAuthOffer, - signToken, - verifyLoginSignature, -} from "@metastate-foundation/auth"; -import { registerSession } from "../middleware/auth"; -import type { EVaultProfileService } from "../services/EVaultProfileService"; - -const JWT_SECRET = process.env.PROFILE_EDITOR_JWT_SECRET!; - -export class AuthController { - private eventEmitter: EventEmitter; - private evaultService: EVaultProfileService; - - constructor(evaultService: EVaultProfileService) { - this.eventEmitter = new EventEmitter(); - this.evaultService = evaultService; - } - - sseStream = async (req: Request, res: Response) => { - const { id } = req.params; - - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }); - - const handler = (data: any) => { - res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - this.eventEmitter.on(id, handler); - - const heartbeatInterval = setInterval(() => { - try { - res.write(`: heartbeat\n\n`); - } catch { - clearInterval(heartbeatInterval); - } - }, 30000); - - req.on("close", () => { - clearInterval(heartbeatInterval); - this.eventEmitter.off(id, handler); - res.end(); - }); - - req.on("error", () => { - clearInterval(heartbeatInterval); - this.eventEmitter.off(id, handler); - res.end(); - }); - }; - - getOffer = async (_req: Request, res: Response) => { - const baseUrl = process.env.PUBLIC_PROFILE_EDITOR_BASE_URL; - if (!baseUrl) { - return res - .status(500) - .json({ error: "PUBLIC_PROFILE_EDITOR_BASE_URL not configured" }); - } - - const offer = buildAuthOffer({ - baseUrl, - platform: "profile-editor", - }); - - res.json({ uri: offer.uri }); - }; - - login = async (req: Request, res: Response) => { - try { - const { ename, session, signature } = req.body; - - if (!ename) { - return res.status(400).json({ error: "ename is required" }); - } - if (!session) { - return res.status(400).json({ error: "session is required" }); - } - if (!signature) { - return res.status(400).json({ error: "signature is required" }); - } - - const registryBaseUrl = process.env.PUBLIC_REGISTRY_URL; - if (!registryBaseUrl) { - return res - .status(500) - .json({ error: "Server configuration error" }); - } - - const result = await verifyLoginSignature({ - eName: ename, - signature, - session, - registryBaseUrl, - }); - - if (!result.valid) { - return res.status(401).json({ - error: "Invalid signature", - message: result.error, - }); - } - - const userId = ename; - const token = signToken({ userId }, JWT_SECRET); - await registerSession(userId, ename, token); - - let name: string | undefined; - try { - const profile = await this.evaultService.getProfile(ename); - name = profile.name ?? ename; - } catch { - name = ename; - } - - const data = { - user: { id: userId, ename, name }, - token, - }; - - this.eventEmitter.emit(session, data); - res.status(200).json(data); - } catch (error) { - console.error("Error during login:", error); - res.status(500).json({ error: "Internal server error" }); - } - }; -} diff --git a/platforms/profile-editor/api/src/controllers/DiscoveryController.ts b/platforms/profile-editor/api/src/controllers/DiscoveryController.ts deleted file mode 100644 index e6fe5bc04..000000000 --- a/platforms/profile-editor/api/src/controllers/DiscoveryController.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Request, Response } from "express"; -import { UserSearchService } from "../services/UserSearchService"; - -export class DiscoveryController { - private userSearchService: UserSearchService; - - constructor() { - this.userSearchService = new UserSearchService(); - } - - discover = async (req: Request, res: Response) => { - try { - const { q, page, limit, sortBy } = req.query; - - const pageNum = Math.max(1, parseInt(page as string) || 1); - const limitNum = Math.min( - 100, - Math.max(1, parseInt(limit as string) || 12), - ); - - const query = ((q as string) ?? "").trim(); - - const results = query - ? await this.userSearchService.searchUsers( - query, - pageNum, - limitNum, - (sortBy as string) || "relevance", - ) - : await this.userSearchService.listPublicUsers( - pageNum, - limitNum, - ); - - res.setHeader( - "Cache-Control", - "no-store, no-cache, must-revalidate, proxy-revalidate", - ); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - res.json(results); - } catch (error: any) { - console.error("Discovery error:", error.message); - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.status(500).json({ error: "Search service unavailable" }); - } - }; -} diff --git a/platforms/profile-editor/api/src/controllers/FileProxyController.ts b/platforms/profile-editor/api/src/controllers/FileProxyController.ts deleted file mode 100644 index f8a8510bb..000000000 --- a/platforms/profile-editor/api/src/controllers/FileProxyController.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Request, Response, RequestHandler } from "express"; -import multer from "multer"; -import FormData from "form-data"; -import axios from "axios"; -import jwt from "jsonwebtoken"; - -const upload = multer({ - limits: { fileSize: 100 * 1024 * 1024 }, - storage: multer.memoryStorage(), -}); - -function mintFmToken(userId: string): string { - const secret = process.env.FILE_MANAGER_JWT_SECRET; - if (!secret) throw new Error("FILE_MANAGER_JWT_SECRET not configured"); - return jwt.sign({ userId }, secret, { expiresIn: "1h" }); -} - -function getFmBaseUrl(): string { - const url = process.env.PUBLIC_FILE_MANAGER_BASE_URL; - if (!url) throw new Error("PUBLIC_FILE_MANAGER_BASE_URL not configured"); - return url; -} - -async function handleUpload(req: Request, res: Response): Promise { - try { - if (!req.file) { - res.status(400).json({ error: "No file provided" }); - return; - } - - const userId = req.user?.ename; - if (!userId) { - res.status(401).json({ error: "Authentication required" }); - return; - } - - const token = mintFmToken(userId); - const form = new FormData(); - form.append("file", req.file.buffer, { - filename: req.file.originalname, - contentType: req.file.mimetype, - }); - - const response = await axios.post( - `${getFmBaseUrl()}/api/files`, - form, - { - headers: { - ...form.getHeaders(), - Authorization: `Bearer ${token}`, - }, - maxContentLength: Infinity, - maxBodyLength: Infinity, - }, - ); - - res.json(response.data); - } catch (error: any) { - console.error("File proxy error:", error?.response?.data ?? error.message); - const status = error?.response?.status ?? 500; - const message = error?.response?.data?.error ?? "Failed to upload file"; - res.status(status).json({ error: message }); - } -} - -export const fileProxyUpload: RequestHandler[] = [ - upload.single("file") as RequestHandler, - handleUpload as RequestHandler, -]; diff --git a/platforms/profile-editor/api/src/controllers/ProfileController.ts b/platforms/profile-editor/api/src/controllers/ProfileController.ts deleted file mode 100644 index 27b38b256..000000000 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { Request, Response } from "express"; -import { EVaultProfileService } from "../services/EVaultProfileService"; -import type { EVaultSyncService } from "../services/EVaultSyncService"; -import type { - ProfileUpdatePayload, - ProfessionalProfile, - WorkExperience, - Education, - SocialLink, -} from "../types/profile"; - -export class ProfileController { - private evaultService: EVaultProfileService; - private syncService?: EVaultSyncService; - - constructor(evaultService: EVaultProfileService) { - this.evaultService = evaultService; - } - - setSyncService(syncService: EVaultSyncService) { - this.syncService = syncService; - } - - /** TEMPORARY: allow `?as=ename` query param to impersonate another user for testing. */ - private resolveEname(req: Request): string | undefined { - const override = req.query.as as string | undefined; - if (override) { - console.warn(`[profile] ADMIN OVERRIDE: acting as ${override} (real user: ${req.user?.ename})`); - return override; - } - return req.user?.ename; - } - - /** - * Non-blocking update: reads from eVault, merges, returns the merged - * profile immediately, fires the eVault write in the background. - */ - private async optimisticUpdate( - ename: string, - data: Partial, - res: Response, - ) { - console.log(`[controller] optimisticUpdate ${ename}: keys=[${Object.keys(data).join(",")}] avatar=${(data as any).avatar ?? "N/A"} banner=${(data as any).banner ?? "N/A"}`); - const { profile, persisted } = await this.evaultService.prepareUpdate(ename, data); - console.log(`[controller] optimisticUpdate ${ename}: returning avatar=${profile.professional.avatar ?? "NONE"} banner=${profile.professional.banner ?? "NONE"}`); - // Fire eVault write in background — don't block the response - persisted - .then(() => { - console.log(`[controller] bg write ${ename}: SUCCESS`); - }) - .catch((err) => { - console.error(`[controller] bg write ${ename}: FAILED:`, err.message); - }); - this.syncService?.syncUserToSearchDb(profile); - res.json(profile); - } - - getProfile = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const isOwnProfile = !req.query.as && req.user?.ename === ename; - const profile = isOwnProfile - ? await this.evaultService.getProfile(ename) - : await this.evaultService.getFreshProfile(ename); - res.json(profile); - } catch (error: any) { - console.error("Error fetching profile:", error.message); - res.status(500).json({ error: "Failed to fetch profile" }); - } - }; - - updateProfile = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const payload: ProfileUpdatePayload = req.body; - console.log(`[profile] PATCH ${ename}:`, Object.keys(payload)); - - await this.optimisticUpdate(ename, payload, res); - } catch (error: any) { - console.error(`[profile] PATCH failed:`, error.message); - res.status(500).json({ error: "Failed to update profile" }); - } - }; - - updateWorkExperience = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const workExperience: WorkExperience[] = req.body; - if (!Array.isArray(workExperience)) { - return res - .status(400) - .json({ error: "Body must be an array of work experience entries" }); - } - - await this.optimisticUpdate(ename, { workExperience }, res); - } catch (error: any) { - console.error(`[profile] work-experience failed:`, error.message); - res.status(500).json({ error: "Failed to update work experience" }); - } - }; - - updateEducation = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const education: Education[] = req.body; - if (!Array.isArray(education)) { - return res - .status(400) - .json({ error: "Body must be an array of education entries" }); - } - - console.log(`[profile] education ${ename}: ${education.length} entries`); - await this.optimisticUpdate(ename, { education }, res); - } catch (error: any) { - console.error(`[profile] education failed:`, error.message, error.stack); - res.status(500).json({ error: "Failed to update education" }); - } - }; - - updateSkills = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const skills: string[] = req.body; - if (!Array.isArray(skills)) { - return res - .status(400) - .json({ error: "Body must be an array of skill strings" }); - } - - await this.optimisticUpdate(ename, { skills }, res); - } catch (error: any) { - console.error(`[profile] skills failed:`, error.message); - res.status(500).json({ error: "Failed to update skills" }); - } - }; - - updateSocialLinks = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const socialLinks: SocialLink[] = req.body; - if (!Array.isArray(socialLinks)) { - return res - .status(400) - .json({ error: "Body must be an array of social link entries" }); - } - - await this.optimisticUpdate(ename, { socialLinks }, res); - } catch (error: any) { - console.error(`[profile] social-links failed:`, error.message); - res.status(500).json({ error: "Failed to update social links" }); - } - }; - - getPublicProfile = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - if (!ename) { - return res.status(400).json({ error: "ename is required" }); - } - - const profile = await this.evaultService.getPublicProfile(ename); - if (!profile) { - return res.status(403).json({ error: "This profile is private" }); - } - - res.json(profile); - } catch (error: any) { - console.error("Error fetching public profile:", error.message); - res.status(500).json({ error: "Failed to fetch profile" }); - } - }; - - private canAccessAsset( - profile: { professional: { isPublic?: boolean }; ename: string }, - req: Request, - ): boolean { - if (profile.professional.isPublic) return true; - if (req.user?.ename === profile.ename) return true; - return true; - } - - /** - * Resolve the identity to use for the file-manager JWT. - * Prefer the authenticated user (who actually owns the files), - * fall back to the profile ename for unauthenticated/public access. - */ - private fileOwner(req: Request, profileEname: string): string { - return req.user?.ename ?? profileEname; - } - - getProfileAvatar = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.avatar; - if (!fileId) { - console.log(`[profile] avatar ${ename}: no fileId set, keys=[${Object.keys(profile.professional).join(",")}]`); - return res.status(404).json({ error: "No avatar set" }); - } - - const owner = this.fileOwner(req, ename); - console.log(`[profile] avatar ${ename}: proxying fileId=${fileId} as=${owner}`); - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, owner, res); - } catch (error: any) { - console.error("Error proxying avatar:", error.message); - res.status(500).json({ error: "Failed to fetch avatar" }); - } - }; - - getProfileBanner = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.banner; - if (!fileId) { - console.log(`[profile] banner ${ename}: no fileId set, keys=[${Object.keys(profile.professional).join(",")}]`); - return res.status(404).json({ error: "No banner set" }); - } - - const owner = this.fileOwner(req, ename); - console.log(`[profile] banner ${ename}: proxying fileId=${fileId} as=${owner}`); - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, owner, res); - } catch (error: any) { - console.error("Error proxying banner:", error.message); - res.status(500).json({ error: "Failed to fetch banner" }); - } - }; - - getProfileCv = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.cvFileId; - if (!fileId) { - return res.status(404).json({ error: "No CV uploaded" }); - } - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, this.fileOwner(req, ename), res); - } catch (error: any) { - console.error("Error proxying CV:", error.message); - res.status(500).json({ error: "Failed to fetch CV" }); - } - }; - - getProfileVideo = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.videoIntroFileId; - if (!fileId) { - return res.status(404).json({ error: "No video uploaded" }); - } - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, this.fileOwner(req, ename), res); - } catch (error: any) { - console.error("Error proxying video:", error.message); - res.status(500).json({ error: "Failed to fetch video" }); - } - }; -} diff --git a/platforms/profile-editor/api/src/controllers/WebhookController.ts b/platforms/profile-editor/api/src/controllers/WebhookController.ts deleted file mode 100644 index ed0f91c47..000000000 --- a/platforms/profile-editor/api/src/controllers/WebhookController.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Request, Response } from "express"; -import { Web3Adapter } from "web3-adapter"; -import { UserSearchService } from "../services/UserSearchService"; -import { downloadUrlAndUploadToFileManager } from "../utils/file-proxy"; - -export class WebhookController { - private userSearchService: UserSearchService; - private adapter: Web3Adapter; - - constructor(adapter: Web3Adapter) { - this.userSearchService = new UserSearchService(); - this.adapter = adapter; - } - - handleWebhook = async (req: Request, res: Response) => { - try { - const schemaId = req.body.schemaId; - const globalId = req.body.id; - console.log(`[webhook] incoming: schemaId=${schemaId} globalId=${globalId} w3id=${req.body.w3id ?? "N/A"} keys=[${Object.keys(req.body.data ?? {}).join(",")}]`); - const mapping = Object.values(this.adapter.mapping).find( - (m) => m.schemaId === schemaId, - ); - - this.adapter.addToLockedIds(globalId); - - if (!mapping) { - return res.status(200).send("Unknown schema, skipping"); - } - - const local = await this.adapter.fromGlobal({ - data: req.body.data, - mapping, - }); - - const localId = await this.adapter.mappingDb.getLocalId(globalId); - - if (mapping.tableName === "users") { - await this.handleUserWebhook( - local.data, - localId, - globalId, - req.body, - ); - } else if (mapping.tableName === "professional_profiles") { - await this.handleProfessionalProfileWebhook( - local.data, - localId, - globalId, - req.body, - ); - } - - res.status(200).send(); - } catch (e) { - console.error("Webhook error:", e); - res.status(200).send(); - } - }; - - private async handleUserWebhook( - localData: any, - localId: string | null, - globalId: string, - rawBody: any, - ) { - const ename = localData.ename || rawBody.w3id; - if (!ename) return; - - const userData: any = { - ename, - handle: localData.handle, - name: rawBody.data?.displayName || localData.name, - bio: localData.bio, - isVerified: localData.isVerified ?? false, - isPublic: localData.isPublic !== false, - isArchived: localData.isArchived ?? false, - }; - - if (localData.avatar) userData.avatar = localData.avatar; - if (localData.banner) userData.banner = localData.banner; - - // If the source platform sent a URL (Blabsy/Pictique) instead of a - // file-manager ID, download the image and upload it to file-manager. - if (!userData.avatar && rawBody.data?.avatarUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.avatarUrl, ename); - if (fileId) userData.avatar = fileId; - } - if (!userData.banner && rawBody.data?.bannerUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.bannerUrl, ename); - if (fileId) userData.banner = fileId; - } - - if (localData.location) userData.location = localData.location; - - const user = await this.userSearchService.upsertFromWebhook(userData); - - await this.adapter.mappingDb.storeMapping({ - localId: user.id, - globalId, - }); - this.adapter.addToLockedIds(user.id); - this.adapter.addToLockedIds(globalId); - } - - private async handleProfessionalProfileWebhook( - localData: any, - localId: string | null, - globalId: string, - rawBody: any, - ) { - const ename = rawBody.w3id; - if (!ename) return; - - console.log(`[webhook] professional_profile ${ename}: avatar=${localData.avatar ?? "NONE"} banner=${localData.banner ?? "NONE"} keys=[${Object.keys(localData).join(",")}]`); - - const profileData: any = { ename }; - - if (localData.name || rawBody.data?.displayName) { - profileData.name = rawBody.data?.displayName || localData.name; - } - if (localData.headline) profileData.headline = localData.headline; - if (localData.bio) profileData.bio = localData.bio; - if (localData.avatar) - profileData.avatar = localData.avatar; - if (localData.banner) - profileData.banner = localData.banner; - - // If the source platform sent a URL instead of a file-manager ID, - // download the image and upload it to file-manager. - if (!profileData.avatar && rawBody.data?.avatarUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.avatarUrl, ename); - if (fileId) profileData.avatar = fileId; - } - if (!profileData.banner && rawBody.data?.bannerUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.bannerUrl, ename); - if (fileId) profileData.banner = fileId; - } - - if (localData.cvFileId) profileData.cvFileId = localData.cvFileId; - if (localData.videoIntroFileId) - profileData.videoIntroFileId = localData.videoIntroFileId; - if (localData.location) profileData.location = localData.location; - if (localData.skills) profileData.skills = localData.skills; - if (localData.isPublic !== undefined) - profileData.isPublic = localData.isPublic; - - const user = - await this.userSearchService.upsertFromWebhook(profileData); - - await this.adapter.mappingDb.storeMapping({ - localId: user.id, - globalId, - }); - this.adapter.addToLockedIds(user.id); - this.adapter.addToLockedIds(globalId); - } -} diff --git a/platforms/profile-editor/api/src/controllers/auth.ts b/platforms/profile-editor/api/src/controllers/auth.ts new file mode 100644 index 000000000..d76b54934 --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/auth.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; +import { + buildAuthOffer, + signToken, + verifyLoginSignature, +} from "@metastate-foundation/auth"; +import type { Request, Response } from "express"; +import { env } from "../env"; +import type { UserService } from "../services/UserService"; + +export class AuthController { + private events = new EventEmitter(); + + constructor(private users: UserService) {} + + getOffer = async (_req: Request, res: Response) => { + const offer = buildAuthOffer({ + baseUrl: env.baseUrl, + platform: "profile-editor", + }); + res.json({ uri: offer.uri }); + }; + + sseStream = async (req: Request, res: Response) => { + const { id } = req.params; + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const handler = (data: unknown) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + this.events.on(id, handler); + + const heartbeat = setInterval(() => { + try { + res.write(": heartbeat\n\n"); + } catch { + clearInterval(heartbeat); + } + }, 30000); + + const cleanup = () => { + clearInterval(heartbeat); + this.events.off(id, handler); + res.end(); + }; + req.on("close", cleanup); + req.on("error", cleanup); + }; + + login = async (req: Request, res: Response) => { + try { + const { ename, session, signature } = req.body; + + const result = await verifyLoginSignature({ + eName: ename, + signature, + session, + registryBaseUrl: env.registryUrl, + }); + + if (!result.valid) { + return res.status(401).json({ + error: "Invalid signature", + message: result.error, + }); + } + + const token = signToken({ userId: ename }, env.jwtSecret); + + let name = ename; + try { + name = (await this.users.findByEname(ename))?.name ?? ename; + } catch { + /* fall back to ename */ + } + + const data = { user: { id: ename, ename, name }, token }; + this.events.emit(session, data); + res.status(200).json(data); + } catch (error) { + console.error("Error during login:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/profile-editor/api/src/controllers/discover.ts b/platforms/profile-editor/api/src/controllers/discover.ts new file mode 100644 index 000000000..d175347f6 --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/discover.ts @@ -0,0 +1,110 @@ +import type { Request, Response } from "express"; +import { AppDataSource } from "../db"; +import { ProfessionalProfile } from "../entities/ProfessionalProfile"; +import { User } from "../entities/User"; + +interface RawRow { + id: string; + ename: string; + name: string | null; + handle: string | null; + bio: string | null; + avatar: string | null; + headline: string | null; + location: string | null; + skills: string[] | null; + isverified: boolean; +} + +/** + * Discovery over the local source-of-truth tables: public professional + * profiles joined to their base identity. avatar is the public URL so the + * client renders it directly. + */ +export class DiscoverController { + discover = async (req: Request, res: Response) => { + try { + const q = ((req.query.q as string) ?? "").trim(); + const skills = ((req.query.skills as string) ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const skillsFilter = skills.length > 0 ? skills : null; + const page = Math.max( + 1, + Number.parseInt(req.query.page as string) || 1, + ); + const limit = Math.min( + 100, + Math.max(1, Number.parseInt(req.query.limit as string) || 12), + ); + + const base = AppDataSource.getRepository(ProfessionalProfile) + .createQueryBuilder("p") + .innerJoin(User, "u", "u.ename = p.ename") + .where("p.isPublic = :pub", { pub: true }) + .andWhere("COALESCE(u.isArchived, false) = false"); + + if (q) { + base.andWhere( + `(COALESCE(u.name,'') ILIKE :q OR u.ename ILIKE :q OR COALESCE(u.handle,'') ILIKE :q + OR COALESCE(p.headline,'') ILIKE :q OR COALESCE(p.bio,'') ILIKE :q OR COALESCE(p.location,'') ILIKE :q)`, + { q: `%${q}%` }, + ); + } + if (skillsFilter) { + base.andWhere("p.skills && :skills", { skills: skillsFilter }); + } + + const total = await base.getCount(); + + const rows = (await base + .clone() + .select([ + "p.id AS id", + "u.ename AS ename", + "u.name AS name", + "u.handle AS handle", + "p.bio AS bio", + "u.avatarUrl AS avatar", + "p.headline AS headline", + "p.location AS location", + "p.skills AS skills", + "u.isVerified AS isverified", + ]) + .orderBy("u.isVerified", "DESC") + .addOrderBy("u.name", "ASC") + .offset((page - 1) * limit) + .limit(limit) + .getRawMany()) as RawRow[]; + + res.setHeader( + "Cache-Control", + "no-store, no-cache, must-revalidate, proxy-revalidate", + ); + res.json({ + query: q, + skills: skillsFilter, + results: rows.map((r) => ({ + id: r.id, + ename: r.ename, + name: r.name, + handle: r.handle, + bio: r.bio, + avatar: r.avatar ?? null, + headline: r.headline, + location: r.location, + skills: r.skills, + isVerified: r.isverified, + })), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }); + } catch (error) { + console.error("Discovery error:", (error as Error).message); + res.status(500).json({ error: "Search service unavailable" }); + } + }; +} diff --git a/platforms/profile-editor/api/src/controllers/files.ts b/platforms/profile-editor/api/src/controllers/files.ts new file mode 100644 index 000000000..9a9069b72 --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/files.ts @@ -0,0 +1,71 @@ +import type { Request, RequestHandler, Response } from "express"; +import multer from "multer"; +import { adapter } from "../web3adapter/watchers/subscriber"; + +// Server-side allowlist — the returned public URL is rendered directly in +// /