diff --git a/.env.example b/.env.example index c62b15c2f..07386de5c 100644 --- a/.env.example +++ b/.env.example @@ -92,7 +92,7 @@ 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:3006" +PUBLIC_PROFILE_EDITOR_BASE_URL=http://localhost:3007 DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" diff --git a/infrastructure/dev-sandbox/src/routes/+page.svelte b/infrastructure/dev-sandbox/src/routes/+page.svelte index fa810dad0..de6147f80 100644 --- a/infrastructure/dev-sandbox/src/routes/+page.svelte +++ b/infrastructure/dev-sandbox/src/routes/+page.svelte @@ -49,6 +49,7 @@ interface Identity { w3id: string; uri: string; keyId: string; + displayName?: string; bearerToken?: string; tokenExpiresAt?: number; } @@ -468,6 +469,14 @@ async function doProvision() { identity.w3id, profile, ); + const provisionedKeyId = identity.keyId; + const next = identities.map((id) => + id.keyId === provisionedKeyId + ? { ...id, displayName: profile.displayName } + : id, + ); + identities = next; + saveIdentities(next); addLog("success", "UserProfile created", profile.displayName); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -826,7 +835,7 @@ async function doSign() {

Selected identity

diff --git a/platforms/file-manager/api/src/controllers/FileController.ts b/platforms/file-manager/api/src/controllers/FileController.ts index a9af2540d..96c071d05 100644 --- a/platforms/file-manager/api/src/controllers/FileController.ts +++ b/platforms/file-manager/api/src/controllers/FileController.ts @@ -453,7 +453,7 @@ export class FileController { } const { id } = req.params; - const { displayName, description, folderId } = req.body; + const { displayName, description, folderId, isPublic } = req.body; const file = await this.fileService.updateFile( id, @@ -473,6 +473,11 @@ export class FileController { .json({ error: "File not found or not authorized" }); } + if (typeof isPublic === "boolean") { + await this.fileService.setFilePublic(id, isPublic); + file.isPublic = isPublic; + } + res.json({ id: file.id, name: file.name, @@ -483,6 +488,7 @@ export class FileController { md5Hash: file.md5Hash, ownerId: file.ownerId, folderId: file.folderId, + isPublic: file.isPublic, createdAt: file.createdAt, updatedAt: file.updatedAt, }); @@ -567,6 +573,41 @@ export class FileController { } }; + /** + * Serves a file publicly. Only files explicitly marked isPublic=true + * are served; all others return 404. + */ + publicPreview = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const file = await this.fileService.getFileByIdPublic(id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + if (file.url) { + return res.redirect(file.url); + } + + if (!file.data) { + return res.status(410).json({ error: "File data unavailable" }); + } + + res.setHeader("Content-Type", file.mimeType); + res.setHeader( + "Content-Disposition", + `inline; filename="${file.name}"`, + ); + res.setHeader("Content-Length", file.size.toString()); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.send(file.data); + } catch (error) { + console.error("Error serving public file:", error); + res.status(500).json({ error: "Failed to serve file" }); + } + }; + deleteFile = async (req: Request, res: Response) => { try { if (!req.user) { diff --git a/platforms/file-manager/api/src/database/entities/File.ts b/platforms/file-manager/api/src/database/entities/File.ts index 5cbf9e488..3fa5cebd3 100644 --- a/platforms/file-manager/api/src/database/entities/File.ts +++ b/platforms/file-manager/api/src/database/entities/File.ts @@ -44,6 +44,9 @@ export class File { @Column({ type: "text", nullable: true }) url!: string | null; + @Column({ default: false }) + isPublic!: boolean; + @Column() ownerId!: string; diff --git a/platforms/file-manager/api/src/database/migrations/1775700000000-AddIsPublicToFiles.ts b/platforms/file-manager/api/src/database/migrations/1775700000000-AddIsPublicToFiles.ts new file mode 100644 index 000000000..eb10298a2 --- /dev/null +++ b/platforms/file-manager/api/src/database/migrations/1775700000000-AddIsPublicToFiles.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddIsPublicToFiles1775700000000 implements MigrationInterface { + name = 'AddIsPublicToFiles1775700000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "files" ADD "isPublic" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "files" DROP COLUMN "isPublic"`); + } +} diff --git a/platforms/file-manager/api/src/index.ts b/platforms/file-manager/api/src/index.ts index 9c3a50571..ac557dacc 100644 --- a/platforms/file-manager/api/src/index.ts +++ b/platforms/file-manager/api/src/index.ts @@ -80,6 +80,7 @@ app.get("/api/auth/offer", authController.getOffer); app.post("/api/auth", authController.login); app.get("/api/auth/sessions/:id", authController.sseStream); app.post("/api/webhook", webhookController.handleWebhook); +app.get("/api/public/files/:id", fileController.publicPreview); // Protected routes (auth required) app.use(authMiddleware); diff --git a/platforms/file-manager/api/src/services/FileService.ts b/platforms/file-manager/api/src/services/FileService.ts index bf71b464b..db92ce9b5 100644 --- a/platforms/file-manager/api/src/services/FileService.ts +++ b/platforms/file-manager/api/src/services/FileService.ts @@ -114,6 +114,20 @@ export class FileService { return savedFile; } + async getFileByIdPublic(id: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id, isPublic: true }, + }); + if (!file || file.name === SOFT_DELETED_FILE_NAME) { + return null; + } + return file; + } + + async setFilePublic(id: string, isPublic: boolean): Promise { + await this.fileRepository.update(id, { isPublic }); + } + async getFileById(id: string, userId: string): Promise { const file = await this.fileRepository.findOne({ where: { id }, diff --git a/platforms/marketplace/client/client/src/data/apps.json b/platforms/marketplace/client/client/src/data/apps.json index c13a2dad9..7a7ec1e85 100644 --- a/platforms/marketplace/client/client/src/data/apps.json +++ b/platforms/marketplace/client/client/src/data/apps.json @@ -87,5 +87,13 @@ "category": "Storage", "logoUrl": "/emover.png", "url": "https://emover.w3ds.metastate.foundation" + }, + { + "id": "profile-editor", + "name": "Profile Editor", + "description": "Create and manage your professional profile across the W3DS ecosystem. Showcase your skills, experience, and credentials.", + "category": "Identity", + "logoUrl": "/profile-editor.png", + "url": "https://profile-editor.w3ds.metastate.foundation" } ] diff --git a/platforms/marketplace/client/client/src/pages/app-detail.tsx b/platforms/marketplace/client/client/src/pages/app-detail.tsx index 1af922069..fc895e955 100644 --- a/platforms/marketplace/client/client/src/pages/app-detail.tsx +++ b/platforms/marketplace/client/client/src/pages/app-detail.tsx @@ -46,6 +46,10 @@ const appDetails: Record m.schemaId === schemaId ); - this.adapter.addToLockedIds(globalId); - if (!mapping) throw new Error(); + if (!mapping) { + console.log(`[webhook] skipping unknown schema ${schemaId} for ${globalId}`); + return res.status(200).send(); + } + + this.adapter.addToLockedIds(globalId); const local = await this.adapter.fromGlobal({ data: req.body.data, mapping, diff --git a/platforms/profile-editor/api/src/controllers/ProfileController.ts b/platforms/profile-editor/api/src/controllers/ProfileController.ts index 2597a4c9f..27b38b256 100644 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ b/platforms/profile-editor/api/src/controllers/ProfileController.ts @@ -40,9 +40,9 @@ export class ProfileController { data: Partial, res: Response, ) { - console.log(`[controller] optimisticUpdate ${ename}: keys=[${Object.keys(data).join(",")}] avatarFileId=${(data as any).avatarFileId ?? "N/A"} bannerFileId=${(data as any).bannerFileId ?? "N/A"}`); + 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 avatarFileId=${profile.professional.avatarFileId ?? "NONE"} bannerFileId=${profile.professional.bannerFileId ?? "NONE"}`); + 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(() => { @@ -221,7 +221,7 @@ export class ProfileController { return res.status(403).json({ error: "This profile is private" }); } - const fileId = profile.professional.avatarFileId; + 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" }); @@ -249,7 +249,7 @@ export class ProfileController { return res.status(403).json({ error: "This profile is private" }); } - const fileId = profile.professional.bannerFileId; + 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" }); diff --git a/platforms/profile-editor/api/src/controllers/WebhookController.ts b/platforms/profile-editor/api/src/controllers/WebhookController.ts index a767088c9..ed0f91c47 100644 --- a/platforms/profile-editor/api/src/controllers/WebhookController.ts +++ b/platforms/profile-editor/api/src/controllers/WebhookController.ts @@ -1,6 +1,7 @@ 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; @@ -75,8 +76,20 @@ export class WebhookController { isArchived: localData.isArchived ?? false, }; - if (localData.avatarFileId) userData.avatarFileId = localData.avatarFileId; - if (localData.bannerFileId) userData.bannerFileId = localData.bannerFileId; + 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); @@ -98,7 +111,7 @@ export class WebhookController { const ename = rawBody.w3id; if (!ename) return; - console.log(`[webhook] professional_profile ${ename}: avatarFileId=${localData.avatarFileId ?? "NONE"} bannerFileId=${localData.bannerFileId ?? "NONE"} keys=[${Object.keys(localData).join(",")}]`); + console.log(`[webhook] professional_profile ${ename}: avatar=${localData.avatar ?? "NONE"} banner=${localData.banner ?? "NONE"} keys=[${Object.keys(localData).join(",")}]`); const profileData: any = { ename }; @@ -107,10 +120,22 @@ export class WebhookController { } if (localData.headline) profileData.headline = localData.headline; if (localData.bio) profileData.bio = localData.bio; - if (localData.avatarFileId) - profileData.avatarFileId = localData.avatarFileId; - if (localData.bannerFileId) - profileData.bannerFileId = localData.bannerFileId; + 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; diff --git a/platforms/profile-editor/api/src/database/entities/User.ts b/platforms/profile-editor/api/src/database/entities/User.ts index 33bb3fea8..8cda1b016 100644 --- a/platforms/profile-editor/api/src/database/entities/User.ts +++ b/platforms/profile-editor/api/src/database/entities/User.ts @@ -24,10 +24,10 @@ export class User { bio!: string; @Column({ nullable: true }) - avatarFileId!: string; + avatar!: string; @Column({ nullable: true }) - bannerFileId!: string; + banner!: string; @Column({ nullable: true }) headline!: string; diff --git a/platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts b/platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts new file mode 100644 index 000000000..0bbde8348 --- /dev/null +++ b/platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RenameAvatarBannerColumns1775600000000 implements MigrationInterface { + name = 'RenameAvatarBannerColumns1775600000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "avatarFileId" TO "avatar"`); + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "bannerFileId" TO "banner"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "avatar" TO "avatarFileId"`); + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "banner" TO "bannerFileId"`); + } +} diff --git a/platforms/profile-editor/api/src/index.ts b/platforms/profile-editor/api/src/index.ts index 6b881e001..2d7882fd4 100644 --- a/platforms/profile-editor/api/src/index.ts +++ b/platforms/profile-editor/api/src/index.ts @@ -19,7 +19,7 @@ import { EVaultSyncService } from "./services/EVaultSyncService"; import { adapter } from "./web3adapter/watchers/subscriber"; const app = express(); -const PORT = process.env.PROFILE_EDITOR_API_PORT || 3006; +const PORT = process.env.PROFILE_EDITOR_API_PORT || 3007; app.use(cors()); app.use(express.json()); diff --git a/platforms/profile-editor/api/src/services/EVaultProfileService.ts b/platforms/profile-editor/api/src/services/EVaultProfileService.ts index 9c69760bb..ab3b4888a 100644 --- a/platforms/profile-editor/api/src/services/EVaultProfileService.ts +++ b/platforms/profile-editor/api/src/services/EVaultProfileService.ts @@ -9,6 +9,11 @@ import type { const PROFESSIONAL_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440009"; const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +function getFileManagerPublicUrl(fileId: string): string { + const base = process.env.PUBLIC_FILE_MANAGER_BASE_URL || "http://localhost:3005"; + return `${base}/api/public/files/${fileId}`; +} + function normalizeEName(eName: string): string { return eName.startsWith("@") ? eName : `@${eName}`; } @@ -121,10 +126,22 @@ function buildPayload(merged: ProfessionalProfile): Record { return payload; } +async function getLocalUser(eName: string) { + const { AppDataSource } = await import("../database/data-source"); + const { User } = await import("../database/entities/User"); + if (!AppDataSource.isInitialized) { + throw new Error("Database not initialized — cannot access local user"); + } + const repo = AppDataSource.getRepository(User); + return { repo, user: await repo.findOneBy({ ename: eName }), User }; +} + function buildFullProfile( eName: string, merged: ProfessionalProfile, userData: UserOntologyData, + localAvatar?: string, + localBanner?: string, ): FullProfile { const name = merged.displayName ?? userData.displayName ?? eName; return { @@ -136,8 +153,8 @@ function buildFullProfile( displayName: merged.displayName, headline: merged.headline, bio: merged.bio, - avatarFileId: merged.avatarFileId, - bannerFileId: merged.bannerFileId, + avatar: localAvatar ?? undefined, + banner: localBanner ?? undefined, cvFileId: merged.cvFileId, videoIntroFileId: merged.videoIntroFileId, email: merged.email, @@ -162,16 +179,9 @@ interface CacheEntry { export class EVaultProfileService { private registryService: RegistryService; - /** - * Short-lived profile cache. Prevents repeated eVault round-trips for - * the same user within a short window (e.g. page load fetches profile - * data + avatar + banner = 3 calls). Invalidated immediately on writes. - */ private cache = new Map(); - private static CACHE_TTL = 30 * 1000; // 30 seconds - /** Longer TTL after writes — rides out eVault eventual consistency window. */ - private static WRITE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - /** Per-user write queue — serializes eVault writes so rapid edits don't race. */ + private static CACHE_TTL = 30 * 1000; + private static WRITE_CACHE_TTL = 5 * 60 * 1000; private writeQueue = new Map>(); constructor(registryService: RegistryService) { @@ -227,12 +237,11 @@ export class EVaultProfileService { const userData = (userNode?.parsed ?? {}) as UserOntologyData; const profData = (professionalNode?.parsed ?? {}) as ProfessionalProfile; - console.log( - `[eVault READ] ${eName}: envelopeId=${professionalNode?.id ?? "NONE"} avatarFileId=${profData.avatarFileId ?? "NONE"} bannerFileId=${profData.bannerFileId ?? "NONE"} keys=[${Object.keys(profData).join(",")}]`, - ); + // Avatar/banner live on the local User entity (file-manager IDs) + const { user: localUser } = await getLocalUser(eName); return { - profile: buildFullProfile(eName, profData, userData), + profile: buildFullProfile(eName, profData, userData, localUser?.avatar, localUser?.banner), envelopeId: professionalNode?.id, }; } @@ -242,14 +251,9 @@ export class EVaultProfileService { const now = Date.now(); const cached = this.cache.get(eName); if (cached && cached.expiresAt > now) { - const ttl = Math.round((cached.expiresAt - now) / 1000); - console.log( - `[eVault CACHE HIT] ${eName}: ttl=${ttl}s avatarFileId=${cached.profile.professional.avatarFileId ?? "NONE"} bannerFileId=${cached.profile.professional.bannerFileId ?? "NONE"}`, - ); return cached.profile; } - console.log(`[eVault CACHE MISS] ${eName}: fetching from eVault`); const { profile, envelopeId } = await this.fetchFromEvault(eName); this.cache.set(eName, { profile, @@ -261,7 +265,6 @@ export class EVaultProfileService { /** Get profile fresh from eVault — bypasses cache, but updates it. */ async getFreshProfile(eName: string): Promise { - console.log(`[eVault FRESH] ${eName}: bypassing cache`); const { profile, envelopeId } = await this.fetchFromEvault(eName); this.cache.set(eName, { profile, @@ -281,19 +284,27 @@ export class EVaultProfileService { /** * Prepare a profile update. Uses the cache as merge base when available - * so rapid back-to-back edits don't clobber each other (the eVault write - * is async and may not have landed yet). Falls back to eVault if no cache. + * so rapid back-to-back edits don't clobber each other. */ async prepareUpdate( eName: string, data: Partial, ): Promise { - console.log( - `[eVault] ${eName}: prepareUpdate keys=[${Object.keys(data).join(", ")}]`, - ); + // Persist avatar/banner to the local User row immediately so + // getProfile returns the correct value right away. + if (data.avatar !== undefined || data.banner !== undefined) { + const { repo, user: localUser, User } = await getLocalUser(eName); + const u = localUser ?? repo.create({ ename: eName }); + if (data.avatar !== undefined) u.avatar = data.avatar; + if (data.banner !== undefined) u.banner = data.banner; + await repo.save(u); + + // Mark new avatar/banner files as publicly accessible + const { markFilePublic } = await import("../utils/file-proxy"); + if (data.avatar) markFilePublic(data.avatar, eName).catch(() => {}); + if (data.banner) markFilePublic(data.banner, eName).catch(() => {}); + } - // Use cache as merge base if available — this prevents a concurrent - // background write from being clobbered by a stale eVault read. const cached = this.cache.get(eName); let baseProfessional: ProfessionalProfile; let userData: UserOntologyData; @@ -307,11 +318,7 @@ export class EVaultProfileService { isVerified: cached.profile.isVerified, } as UserOntologyData; cachedEnvelopeId = cached.envelopeId; - console.log( - `[eVault MERGE-BASE] ${eName}: FROM CACHE — avatarFileId=${baseProfessional.avatarFileId ?? "NONE"} bannerFileId=${baseProfessional.bannerFileId ?? "NONE"} envelopeId=${cachedEnvelopeId ?? "NONE"}`, - ); } else { - // No cache — must read from eVault const { profile, envelopeId } = await this.fetchFromEvault(eName); baseProfessional = profile.professional; userData = { @@ -320,13 +327,9 @@ export class EVaultProfileService { isVerified: profile.isVerified, } as UserOntologyData; cachedEnvelopeId = envelopeId; - console.log( - `[eVault MERGE-BASE] ${eName}: FROM EVAULT — avatarFileId=${baseProfessional.avatarFileId ?? "NONE"} bannerFileId=${baseProfessional.bannerFileId ?? "NONE"} envelopeId=${cachedEnvelopeId ?? "NONE"}`, - ); } const client = await this.getClient(eName); - // Build a minimal MetaEnvelopeNode stub for writeToEvault const existingEnvelope: MetaEnvelopeNode | null = cachedEnvelopeId ? { id: cachedEnvelopeId, ontology: PROFESSIONAL_PROFILE_ONTOLOGY, parsed: {} } : null; @@ -339,11 +342,9 @@ export class EVaultProfileService { const payload = buildPayload(merged); const acl = merged.isPublic === true ? ["*"] : [normalizeEName(eName)]; - console.log( - `[eVault MERGED] ${eName}: avatarFileId=${merged.avatarFileId ?? "NONE"} bannerFileId=${merged.bannerFileId ?? "NONE"} payload keys=[${Object.keys(payload).join(", ")}] acl=${JSON.stringify(acl)}`, - ); - - const profile = buildFullProfile(eName, merged, userData); + // Read local user for the optimistic profile (may have just been updated above) + const { user: freshLocalUser } = await getLocalUser(eName); + const profile = buildFullProfile(eName, merged, userData, freshLocalUser?.avatar, freshLocalUser?.banner); // Immediately update the cache with the optimistic result this.cache.set(eName, { @@ -352,25 +353,24 @@ export class EVaultProfileService { envelopeId: cachedEnvelopeId, }); - const persisted = this.enqueueWrite(eName, () => - this.writeToEvault(client, eName, existingEnvelope, payload, acl), - ); + const persisted = this.enqueueWrite(eName, async () => { + await this.writeToEvault(client, eName, existingEnvelope, payload, acl); + // After eVault write, sync avatar/banner URLs to User ontology + if (data.avatar !== undefined || data.banner !== undefined) { + await this.syncAvatarBannerToUserOntology(client, eName, merged); + } + }); return { profile, persisted }; } - /** - * Enqueue a write for a user — ensures writes are serialized so a - * slow write #1 can't be overwritten by a fast write #2. - */ private enqueueWrite( eName: string, fn: () => Promise, ): Promise { const prev = this.writeQueue.get(eName) ?? Promise.resolve(); - const next = prev.then(fn, fn); // run even if previous failed + const next = prev.then(fn, fn); this.writeQueue.set(eName, next); - // Clean up the queue entry when done next.finally(() => { if (this.writeQueue.get(eName) === next) { this.writeQueue.delete(eName); @@ -386,10 +386,6 @@ export class EVaultProfileService { payload: Record, acl: string[], ): Promise { - console.log( - `[eVault WRITE] ${eName}: starting ${existing ? "UPDATE" : "CREATE"} envelopeId=${existing?.id ?? "NEW"} avatarFileId=${payload.avatarFileId ?? "NONE"} bannerFileId=${payload.bannerFileId ?? "NONE"}`, - ); - try { if (existing) { const result = await client.request(UPDATE_MUTATION, { @@ -402,17 +398,12 @@ export class EVaultProfileService { }); if (result.updateMetaEnvelope.errors?.length) { - const errMsg = result.updateMetaEnvelope.errors - .map((e) => e.message) - .join("; "); - console.error(`[eVault WRITE FAIL] ${eName}: UPDATE errors: ${errMsg}`); - throw new Error(errMsg); + throw new Error( + result.updateMetaEnvelope.errors + .map((e) => e.message) + .join("; "), + ); } - - const returned = result.updateMetaEnvelope.metaEnvelope?.parsed as Record | undefined; - console.log( - `[eVault WRITE OK] ${eName}: UPDATE response avatarFileId=${returned?.avatarFileId ?? "NONE"} bannerFileId=${returned?.bannerFileId ?? "NONE"} keys=[${returned ? Object.keys(returned).join(",") : "EMPTY"}]`, - ); } else { const result = await client.request(CREATE_MUTATION, { input: { @@ -424,7 +415,6 @@ export class EVaultProfileService { if (result.createMetaEnvelope.errors?.length) { const errors = result.createMetaEnvelope.errors; - console.warn(`[eVault WRITE] ${eName}: CREATE got errors: ${JSON.stringify(errors)}`); const couldBeConflict = errors.some( (e) => e.code === "CREATE_FAILED" || @@ -440,7 +430,6 @@ export class EVaultProfileService { PROFESSIONAL_PROFILE_ONTOLOGY, ); if (raced) { - console.log(`[eVault WRITE] ${eName}: CREATE conflict, falling back to UPDATE on ${raced.id}`); const updateResult = await client.request( UPDATE_MUTATION, { @@ -459,36 +448,69 @@ export class EVaultProfileService { .join("; "), ); } - const returned = updateResult.updateMetaEnvelope.metaEnvelope?.parsed as Record | undefined; - console.log( - `[eVault WRITE OK] ${eName}: fallback UPDATE response avatarFileId=${returned?.avatarFileId ?? "NONE"}`, - ); } else { throw new Error(errors.map((e) => e.message).join("; ")); } - } else { - const returned = result.createMetaEnvelope.metaEnvelope?.parsed as Record | undefined; - console.log( - `[eVault WRITE OK] ${eName}: CREATE response avatarFileId=${returned?.avatarFileId ?? "NONE"} bannerFileId=${returned?.bannerFileId ?? "NONE"}`, - ); } } - // Write succeeded — extend the cache with a long TTL so we ride out - // eVault's eventual-consistency window instead of reading stale data. + // Write succeeded — extend the cache TTL const cached = this.cache.get(eName); if (cached) { cached.expiresAt = Date.now() + EVaultProfileService.WRITE_CACHE_TTL; - console.log(`[eVault CACHE PIN] ${eName}: extended cache TTL to ${EVaultProfileService.WRITE_CACHE_TTL / 1000}s after successful write`); } } catch (err) { - // On write failure, invalidate cache so next read gets fresh data console.error(`[eVault WRITE FAIL] ${eName}: invalidating cache`, (err as Error).message); this.cache.delete(eName); throw err; } } + /** + * Writes avatarUrl / bannerUrl as public file-manager URLs into the + * User ontology so other platforms can render them directly. + */ + private async syncAvatarBannerToUserOntology( + client: GraphQLClient, + eName: string, + profile: ProfessionalProfile, + ): Promise { + try { + const userNode = await this.findMetaEnvelopeByOntology(client, USER_ONTOLOGY); + const existing = (userNode?.parsed ?? {}) as Record; + // Preserve the existing ACL; only default to public for new envelopes + const existingAcl = (userNode as any)?.acl; + + // Only patch avatarUrl/bannerUrl — don't overwrite other User fields + const patch: Record = { ...existing }; + if (profile.avatar) { + patch.avatarUrl = getFileManagerPublicUrl(profile.avatar); + } + if (profile.banner) { + patch.bannerUrl = getFileManagerPublicUrl(profile.banner); + } + + if (userNode) { + await client.request(UPDATE_MUTATION, { + id: userNode.id, + input: { + ontology: USER_ONTOLOGY, + payload: patch, + acl: existingAcl ?? ["*"], + }, + }); + } else { + patch.ename = eName; + patch.displayName = profile.displayName ?? eName; + await client.request(CREATE_MUTATION, { + input: { ontology: USER_ONTOLOGY, payload: patch, acl: ["*"] }, + }); + } + } catch (e) { + console.error("Failed to sync avatar/banner to User ontology:", e); + } + } + async getProfileByEnvelope( eName: string, id: string, diff --git a/platforms/profile-editor/api/src/services/EVaultSyncService.ts b/platforms/profile-editor/api/src/services/EVaultSyncService.ts index a629fda36..5eba9bc0b 100644 --- a/platforms/profile-editor/api/src/services/EVaultSyncService.ts +++ b/platforms/profile-editor/api/src/services/EVaultSyncService.ts @@ -87,7 +87,7 @@ export class EVaultSyncService { private async syncUser(ename: string): Promise { const profile = await this.evaultService.getProfile(ename); - console.log(`[sync] ${ename}: avatarFileId=${profile.professional.avatarFileId ?? "NONE"} bannerFileId=${profile.professional.bannerFileId ?? "NONE"}`); + console.log(`[sync] ${ename}: avatar=${profile.professional.avatar ?? "NONE"} banner=${profile.professional.banner ?? "NONE"}`); await this.userSearchService.upsertFromWebhook({ ename, @@ -97,8 +97,8 @@ export class EVaultSyncService { bio: profile.professional.bio, headline: profile.professional.headline, location: profile.professional.location, - avatarFileId: profile.professional.avatarFileId, - bannerFileId: profile.professional.bannerFileId, + avatar: profile.professional.avatar, + banner: profile.professional.banner, skills: profile.professional.skills, isPublic: profile.professional.isPublic === true, isArchived: false, @@ -115,8 +115,8 @@ export class EVaultSyncService { bio: profile.professional.bio, headline: profile.professional.headline, location: profile.professional.location, - avatarFileId: profile.professional.avatarFileId, - bannerFileId: profile.professional.bannerFileId, + avatar: profile.professional.avatar, + banner: profile.professional.banner, skills: profile.professional.skills, isPublic: profile.professional.isPublic === true, isArchived: false, diff --git a/platforms/profile-editor/api/src/services/UserSearchService.ts b/platforms/profile-editor/api/src/services/UserSearchService.ts index 84d61d14a..bd0b85455 100644 --- a/platforms/profile-editor/api/src/services/UserSearchService.ts +++ b/platforms/profile-editor/api/src/services/UserSearchService.ts @@ -33,7 +33,7 @@ export class UserSearchService { "user.name", "user.handle", "user.bio", - "user.avatarFileId", + "user.avatar", "user.headline", "user.location", "user.skills", @@ -101,7 +101,7 @@ export class UserSearchService { name: user.name, handle: user.handle, bio: user.bio, - avatarFileId: user.avatarFileId, + avatar: user.avatar, headline: user.headline, location: user.location, skills: user.skills, @@ -123,7 +123,7 @@ export class UserSearchService { "user.name", "user.handle", "user.bio", - "user.avatarFileId", + "user.avatar", "user.headline", "user.location", "user.skills", @@ -146,7 +146,7 @@ export class UserSearchService { name: user.name, handle: user.handle, bio: user.bio, - avatarFileId: user.avatarFileId, + avatar: user.avatar, headline: user.headline, location: user.location, skills: user.skills, diff --git a/platforms/profile-editor/api/src/types/profile.ts b/platforms/profile-editor/api/src/types/profile.ts index ee437aee5..38b90219b 100644 --- a/platforms/profile-editor/api/src/types/profile.ts +++ b/platforms/profile-editor/api/src/types/profile.ts @@ -31,8 +31,8 @@ export interface ProfessionalProfile { displayName?: string; headline?: string; bio?: string; - avatarFileId?: string; - bannerFileId?: string; + avatar?: string; + banner?: string; cvFileId?: string; videoIntroFileId?: string; email?: string; @@ -50,8 +50,8 @@ export interface UserOntologyData { username?: string; displayName?: string; bio?: string; - avatarUrl?: string; - bannerUrl?: string; + avatar?: string; + banner?: string; ename?: string; isVerified?: boolean; isPrivate?: boolean; @@ -71,8 +71,8 @@ export interface ProfileUpdatePayload { displayName?: string; headline?: string; bio?: string; - avatarFileId?: string; - bannerFileId?: string; + avatar?: string; + banner?: string; cvFileId?: string; videoIntroFileId?: string; email?: string; diff --git a/platforms/profile-editor/api/src/utils/file-proxy.ts b/platforms/profile-editor/api/src/utils/file-proxy.ts index 323cf0aa7..f160dc369 100644 --- a/platforms/profile-editor/api/src/utils/file-proxy.ts +++ b/platforms/profile-editor/api/src/utils/file-proxy.ts @@ -1,5 +1,6 @@ import axios from "axios"; import jwt from "jsonwebtoken"; +import FormData from "form-data"; import type { Response } from "express"; const FILE_MANAGER_BASE_URL = () => @@ -11,6 +12,152 @@ function mintFmToken(userId: string): string { return jwt.sign({ userId }, secret, { expiresIn: "1h" }); } +import dns from "dns/promises"; +import net from "net"; + +/** + * Validates a URL is safe to fetch (not internal/private). + * Blocks non-https schemes (except data:), loopback, private, and link-local IPs. + */ +async function isUrlAllowed(url: string): Promise { + if (url.startsWith("data:")) return true; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + + const hostname = parsed.hostname; + if (hostname === "localhost" || hostname === "[::1]") return false; + + // Resolve hostname to IP and check for private ranges + let addresses: string[]; + try { + if (net.isIP(hostname)) { + addresses = [hostname]; + } else { + const results = await dns.resolve4(hostname).catch(() => [] as string[]); + const results6 = await dns.resolve6(hostname).catch(() => [] as string[]); + addresses = [...results, ...results6]; + } + } catch { + return false; + } + + for (const addr of addresses) { + if (net.isIPv4(addr)) { + const parts = addr.split(".").map(Number); + if (parts[0] === 127) return false; // 127.0.0.0/8 + if (parts[0] === 10) return false; // 10.0.0.0/8 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; // 172.16.0.0/12 + if (parts[0] === 192 && parts[1] === 168) return false; // 192.168.0.0/16 + if (parts[0] === 169 && parts[1] === 254) return false; // 169.254.0.0/16 + if (parts[0] === 0) return false; // 0.0.0.0/8 + } else if (net.isIPv6(addr)) { + const normalized = addr.toLowerCase(); + if (normalized === "::1") return false; + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return false; // fc00::/7 + if (normalized.startsWith("fe80")) return false; // fe80::/10 + } + } + return true; +} + +/** + * Downloads an image from a URL (HTTP or data: URI) and uploads it to the + * file-manager service, returning the resulting file ID. + * Returns null on any failure so webhook processing can continue. + */ +export async function downloadUrlAndUploadToFileManager( + url: string, + ename: string, +): Promise { + try { + if (!(await isUrlAllowed(url))) { + console.warn("SSRF blocked: disallowed URL", url); + return null; + } + + let buffer: Buffer; + let mimeType = "image/png"; + let filename = "avatar.png"; + + if (url.startsWith("data:")) { + const match = url.match(/^data:([^;]+);base64,(.+)$/); + if (!match) return null; + mimeType = match[1]; + buffer = Buffer.from(match[2], "base64"); + const ext = mimeType.split("/")[1] || "bin"; + filename = `upload.${ext}`; + } else { + const response = await axios.get(url, { + responseType: "arraybuffer", + timeout: 15_000, + maxRedirects: 3, + }); + buffer = Buffer.from(response.data); + const ct = response.headers["content-type"]; + if (ct) mimeType = ct.split(";")[0].trim(); + const ext = mimeType.split("/")[1] || "bin"; + filename = `upload.${ext}`; + } + + const token = mintFmToken(ename); + const form = new FormData(); + form.append("file", buffer, { filename, contentType: mimeType }); + + const res = await axios.post( + `${FILE_MANAGER_BASE_URL()}/api/files`, + form, + { + headers: { + ...form.getHeaders(), + Authorization: `Bearer ${token}`, + }, + timeout: 30_000, + maxContentLength: Infinity, + maxBodyLength: Infinity, + }, + ); + + const fileId = res.data?.id; + if (fileId) { + await markFilePublic(fileId, ename); + } + return fileId ?? null; + } catch (error: any) { + console.error( + "Failed to download/upload avatar or banner:", + error.message, + ); + return null; + } +} + +/** + * Marks a file as publicly accessible in file-manager via PATCH. + */ +export async function markFilePublic(fileId: string, ename: string): Promise { + try { + const token = mintFmToken(ename); + await axios.patch( + `${FILE_MANAGER_BASE_URL()}/api/files/${fileId}`, + { isPublic: true }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + timeout: 10_000, + }, + ); + } catch (error: any) { + console.error("Failed to mark file as public:", error.message); + } +} + export async function proxyFileFromFileManager( fileId: string, ename: string, diff --git a/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json b/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json index 415c03fcd..906b2c9e6 100644 --- a/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json +++ b/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json @@ -7,8 +7,6 @@ "name": "displayName", "headline": "headline", "bio": "bio", - "avatarFileId": "avatarFileId", - "bannerFileId": "bannerFileId", "cvFileId": "cvFileId", "videoIntroFileId": "videoIntroFileId", "location": "location", diff --git a/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json b/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json index 54fef39f6..0801aacc7 100644 --- a/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,6 @@ "handle": "username", "name": "displayName", "bio": "bio", - "avatarFileId": "avatarFileId", - "bannerFileId": "bannerFileId", "ename": "ename", "isVerified": "isVerified", "isPublic": "isPublic", diff --git a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte index d01782a71..18cf0c15f 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte @@ -26,23 +26,21 @@ let bannerBroken = $state(false); let avatarBroken = $state(false); - function avatarSrc(): string | null { - if (avatarPreview) return avatarPreview; - const fid = profile.professional.avatarFileId; - if (fid) { - return `${getProfileAssetUrl(profile.ename, 'avatar')}?v=${encodeURIComponent(fid)}`; - } - return null; - } - - function bannerSrc(): string | null { - if (bannerPreview) return bannerPreview; - const fid = profile.professional.bannerFileId; - if (fid) { - return `${getProfileAssetUrl(profile.ename, 'banner')}?v=${encodeURIComponent(fid)}`; - } - return null; - } + let avatarSrc = $derived( + avatarPreview + ? avatarPreview + : profile.professional.avatar + ? `${getProfileAssetUrl(profile.ename, 'avatar')}?v=${encodeURIComponent(profile.professional.avatar)}` + : null + ); + + let bannerSrc = $derived( + bannerPreview + ? bannerPreview + : profile.professional.banner + ? `${getProfileAssetUrl(profile.ename, 'banner')}?v=${encodeURIComponent(profile.professional.banner)}` + : null + ); async function handleAvatarUpload(e: Event) { const input = e.target as HTMLInputElement; @@ -55,7 +53,7 @@ uploadingAvatar = true; try { const result = await uploadFile(file); - await updateProfile({ avatarFileId: result.id }); + await updateProfile({ avatar: result.id }); avatarPreview = null; toast.success('Avatar updated'); } catch { @@ -77,7 +75,7 @@ uploadingBanner = true; try { const result = await uploadFile(file); - await updateProfile({ bannerFileId: result.id }); + await updateProfile({ banner: result.id }); bannerPreview = null; toast.success('Banner updated'); } catch { @@ -102,8 +100,8 @@
- {#if bannerSrc() && !bannerBroken} - Banner { bannerBroken = true; }} /> + {#if bannerSrc && !bannerBroken} + Banner { bannerBroken = true; }} /> {/if} {#if editable}
@@ -121,8 +119,8 @@
- {#if avatarSrc() && !avatarBroken} - + {#if avatarSrc && !avatarBroken} + {/if} {(profile.name ?? profile.ename ?? '?')[0]?.toUpperCase()} diff --git a/platforms/profile-editor/client/src/lib/stores/discovery.ts b/platforms/profile-editor/client/src/lib/stores/discovery.ts index 5301cf920..b03f45630 100644 --- a/platforms/profile-editor/client/src/lib/stores/discovery.ts +++ b/platforms/profile-editor/client/src/lib/stores/discovery.ts @@ -10,7 +10,7 @@ export interface ProfileSearchResult { bio: string; location: string; skills: string[]; - avatarFileId: string | null; + avatar: string | null; isVerified: boolean; } diff --git a/platforms/profile-editor/client/src/lib/stores/profile.ts b/platforms/profile-editor/client/src/lib/stores/profile.ts index 0eb62ed4d..b10d11b02 100644 --- a/platforms/profile-editor/client/src/lib/stores/profile.ts +++ b/platforms/profile-editor/client/src/lib/stores/profile.ts @@ -40,8 +40,8 @@ export interface ProfileData { displayName?: string; headline?: string; bio?: string; - avatarFileId?: string; - bannerFileId?: string; + avatar?: string; + banner?: string; cvFileId?: string; videoIntroFileId?: string; email?: string; diff --git a/platforms/profile-editor/client/src/lib/utils/axios.ts b/platforms/profile-editor/client/src/lib/utils/axios.ts index 4d9c430d4..903195b55 100644 --- a/platforms/profile-editor/client/src/lib/utils/axios.ts +++ b/platforms/profile-editor/client/src/lib/utils/axios.ts @@ -42,6 +42,6 @@ if (token) { } export const apiClient: AxiosInstance = axios.create({ - baseURL: PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3006', + baseURL: PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3007', headers }); diff --git a/platforms/profile-editor/client/src/lib/utils/file-manager.ts b/platforms/profile-editor/client/src/lib/utils/file-manager.ts index 3041b8a56..88271c4d1 100644 --- a/platforms/profile-editor/client/src/lib/utils/file-manager.ts +++ b/platforms/profile-editor/client/src/lib/utils/file-manager.ts @@ -1,8 +1,5 @@ -import { PUBLIC_PROFILE_EDITOR_BASE_URL } from '$env/static/public'; import { apiClient } from './axios'; -const API_BASE = () => PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3006'; - export async function uploadFile( file: File, onProgress?: (progress: number) => void @@ -27,5 +24,6 @@ export async function uploadFile( export type ProfileAssetType = 'avatar' | 'banner' | 'cv' | 'video'; export function getProfileAssetUrl(ename: string, type: ProfileAssetType): string { - return `${API_BASE()}/api/profiles/${encodeURIComponent(ename)}/${type}`; + const base = apiClient.defaults.baseURL || 'http://localhost:3007'; + return `${base}/api/profiles/${encodeURIComponent(ename)}/${type}`; } diff --git a/services/ontology/schemas/professionalProfile.json b/services/ontology/schemas/professionalProfile.json new file mode 100644 index 000000000..f47a02b8c --- /dev/null +++ b/services/ontology/schemas/professionalProfile.json @@ -0,0 +1,181 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440009", + "title": "ProfessionalProfile", + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "The user's display name" + }, + "headline": { + "type": "string", + "description": "A short professional headline or tagline" + }, + "bio": { + "type": "string", + "description": "Professional biography or summary" + }, + "avatar": { + "type": "string", + "description": "File-manager file ID for the user's profile picture" + }, + "banner": { + "type": "string", + "description": "File-manager file ID for the user's profile banner" + }, + "cvFileId": { + "type": "string", + "description": "File-manager file ID for the user's CV/resume document" + }, + "videoIntroFileId": { + "type": "string", + "description": "File-manager file ID for the user's video introduction" + }, + "email": { + "type": "string", + "format": "email", + "description": "Professional contact email" + }, + "phone": { + "type": "string", + "description": "Professional contact phone number" + }, + "website": { + "type": "string", + "format": "uri", + "description": "Personal or professional website URL" + }, + "location": { + "type": "string", + "description": "Professional location or city" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the professional profile is publicly visible" + }, + "workExperience": { + "type": "array", + "description": "List of work experience entries", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the entry" + }, + "company": { + "type": "string", + "description": "Company or organization name" + }, + "role": { + "type": "string", + "description": "Job title or role" + }, + "description": { + "type": "string", + "description": "Description of responsibilities and achievements" + }, + "startDate": { + "type": "string", + "format": "date", + "description": "Start date of the position" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "End date of the position (omit if current)" + }, + "location": { + "type": "string", + "description": "Location of the position" + }, + "sortOrder": { + "type": "integer", + "description": "Display order" + } + }, + "required": ["company", "role", "startDate", "sortOrder"] + } + }, + "education": { + "type": "array", + "description": "List of education entries", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the entry" + }, + "institution": { + "type": "string", + "description": "Educational institution name" + }, + "degree": { + "type": "string", + "description": "Degree or qualification obtained" + }, + "fieldOfStudy": { + "type": "string", + "description": "Field or area of study" + }, + "startDate": { + "type": "string", + "format": "date", + "description": "Start date" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "End date (omit if ongoing)" + }, + "description": { + "type": "string", + "description": "Additional details about the education" + }, + "sortOrder": { + "type": "integer", + "description": "Display order" + } + }, + "required": ["institution", "degree", "startDate", "sortOrder"] + } + }, + "skills": { + "type": "array", + "description": "List of professional skills", + "items": { + "type": "string" + } + }, + "socialLinks": { + "type": "array", + "description": "List of social media or professional links", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the link" + }, + "platform": { + "type": "string", + "description": "Platform name (e.g. LinkedIn, GitHub, Twitter)" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL to the profile on the platform" + }, + "label": { + "type": "string", + "description": "Optional display label" + } + }, + "required": ["platform", "url"] + } + } + }, + "required": ["displayName"] +}