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}
-
})
{ bannerBroken = true; }} />
+ {#if bannerSrc && !bannerBroken}
+

{ 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"]
+}