diff --git a/.env.example b/.env.example index d81932bd..b31437a3 100644 --- a/.env.example +++ b/.env.example @@ -71,3 +71,5 @@ EREPUTATION_MAPPING_DB_PATH="/path/to/erep/mapping/db" VITE_EREPUTATION_BASE_URL=http://localhost:8765 LOAD_TEST_USER_COUNT=6 + +PUBLIC_EID_WALLET_TOKEN=obtained-from-post-registry-service-/platforms/certification diff --git a/README.md b/README.md index df2b2568..0c4912ec 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - **3003** - Group Charter Manager API - **4001** - DreamSync API - **4002** - eVoting API +- **4003** - Emover API - **5000** - eReputation Service - **5001** - Marketplace Service - **1111** - Pictique API @@ -27,6 +28,7 @@ - **5173** - Pictique Frontend - **3004** - Group Charter Manager Frontend - **3005** - eVoting Frontend +- **3006** - Emover Frontend ### Docker Compose Profiles diff --git a/db/init-multiple-databases.sh b/db/init-multiple-databases.sh new file mode 100755 index 00000000..299ad717 --- /dev/null +++ b/db/init-multiple-databases.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +# Get the list of databases from environment variable +# Default to empty if not set +POSTGRES_MULTIPLE_DATABASES=${POSTGRES_MULTIPLE_DATABASES:-} + +# If no databases specified, exit +if [ -z "$POSTGRES_MULTIPLE_DATABASES" ]; then + echo "No databases specified in POSTGRES_MULTIPLE_DATABASES" + exit 0 +fi + +echo "Creating multiple databases..." + +# Split the comma-separated list and create each database +IFS=',' read -ra DATABASES <<< "$POSTGRES_MULTIPLE_DATABASES" +for db in "${DATABASES[@]}"; do + # Trim whitespace + db=$(echo "$db" | xargs) + + if [ -n "$db" ]; then + # Check if database exists + DB_EXISTS=$(psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$db'" 2>/dev/null || echo "") + + if [ "$DB_EXISTS" = "1" ]; then + echo "Database $db already exists, skipping..." + else + echo "Creating database: $db" + # Create the database directly (not inside a function) + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-EOSQL + CREATE DATABASE "$db"; +EOSQL + fi + fi +done + +echo "Multiple databases created successfully!" + diff --git a/dev-docker-compose.yaml b/dev-docker-compose.yaml index f17b6dbc..b1acfab6 100644 --- a/dev-docker-compose.yaml +++ b/dev-docker-compose.yaml @@ -126,6 +126,19 @@ services: networks: - metastate-network <<: *common-host-access + entrypoint: ["/bin/sh", "-c"] + command: + - | + # Remove any stale PID files before starting Neo4j + # Neo4j stores PID files in /var/lib/neo4j/run/neo4j.pid + rm -f /var/lib/neo4j/run/neo4j.pid 2>/dev/null || true + rm -f /var/lib/neo4j/data/run/neo4j.pid 2>/dev/null || true + rm -f /var/lib/neo4j/data/neo4j.pid 2>/dev/null || true + # Also clean up any other PID files + find /var/lib/neo4j -name "*.pid" -type f -delete 2>/dev/null || true + find /var/lib/neo4j/data -name "*.pid" -type f -delete 2>/dev/null || true + # Start Neo4j with the original entrypoint + exec /startup/docker-entrypoint.sh neo4j healthcheck: test: [ "CMD-SHELL", "cypher-shell -u neo4j -p ${NEO4J_PASSWORD:-neo4j} 'RETURN 1' || exit 1" ] interval: 10s @@ -144,7 +157,7 @@ services: environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - - POSTGRES_MULTIPLE_DATABASES=registry,pictique,evoting,dreamsync,cerberus,group_charter_manager,blabsy_auth,ereputation,marketplace + - POSTGRES_MULTIPLE_DATABASES=registry,pictique,evoting,dreamsync,cerberus,group_charter_manager,blabsy_auth,ereputation,marketplace,emover volumes: - postgres_data:/var/lib/postgresql/data - ./db/init-multiple-databases.sh:/docker-entrypoint-initdb.d/init-multiple-databases.sh diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/codeStyles/Project.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..7643783a --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/codeStyles/codeStyleConfig.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..79ee123c --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/kotlinc.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/kotlinc.xml index 4cb74572..fe63bb67 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/kotlinc.xml +++ b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts index 917f6ab4..349f9618 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts @@ -1,14 +1,14 @@ import { - PUBLIC_REGISTRY_URL, - PUBLIC_PROVISIONER_URL, PUBLIC_EID_WALLET_TOKEN, + PUBLIC_PROVISIONER_URL, + PUBLIC_REGISTRY_URL, } from "$env/static/public"; import type { Store } from "@tauri-apps/plugin-store"; import axios from "axios"; import { GraphQLClient } from "graphql-request"; import NotificationService from "../../services/NotificationService"; -import type { UserController } from "./user"; import type { KeyService } from "./key"; +import type { UserController } from "./user"; const STORE_META_ENVELOPE = ` mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { @@ -176,7 +176,7 @@ export class VaultController { }; if (authToken) { - headers["Authorization"] = `Bearer ${authToken}`; + headers.Authorization = `Bearer ${authToken}`; } await axios.patch(patchUrl, { publicKey }, { headers }); diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index 5027dc51..6628964f 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -198,6 +198,7 @@ onMount(async () => { new URL("/entropy", PUBLIC_REGISTRY_URL).toString(), ); const registryEntropy = entropyRes.data.token; + console.log("Registry entropy:", registryEntropy); const provisionRes = await axios.post( new URL("/provision", PUBLIC_PROVISIONER_URL).toString(), @@ -208,6 +209,7 @@ onMount(async () => { publicKey: await getApplicationPublicKey(), }, ); + console.log("Provision response:", provisionRes.data); if (!provisionRes.data?.success) { throw new Error("Invalid verification code"); diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index 969239e0..5f3093a4 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -1,13 +1,13 @@ -import { Driver } from "neo4j-driver"; +import type { Driver } from "neo4j-driver"; import { W3IDBuilder } from "w3id"; -import { serializeValue, deserializeValue } from "./schema"; -import { - MetaEnvelope, +import { deserializeValue, serializeValue } from "./schema"; +import type { Envelope, + GetAllEnvelopesResult, + MetaEnvelope, MetaEnvelopeResult, - StoreMetaEnvelopeResult, SearchMetaEnvelopesResult, - GetAllEnvelopesResult, + StoreMetaEnvelopeResult, } from "./types"; /** @@ -31,7 +31,7 @@ export class DbService { * @param params - The parameters for the query * @returns The result of the query execution */ - private async runQuery(query: string, params: Record) { + private async runQueryInternal(query: string, params: Record) { const session = this.driver.session(); try { return await session.run(query, params); @@ -40,6 +40,17 @@ export class DbService { } } + /** + * Executes a Cypher query with the given parameters. + * Exposed for cross-evault operations. + * @param query - The Cypher query to execute + * @param params - The parameters for the query + * @returns The result of the query execution + */ + async runQuery(query: string, params: Record) { + return this.runQueryInternal(query, params); + } + /** * Stores a new meta-envelope and its associated envelopes. * @param meta - The meta-envelope data (without ID) @@ -108,7 +119,7 @@ export class DbService { counter++; } - await this.runQuery(cypher.join("\n"), envelopeParams); + await this.runQueryInternal(cypher.join("\n"), envelopeParams); return { metaEnvelope: { @@ -139,10 +150,10 @@ export class DbService { throw new Error("eName is required for searching meta-envelopes"); } - const result = await this.runQuery( + const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { ontology: $ontology, eName: $eName })-[:LINKS_TO]->(e:Envelope) - WHERE + WHERE CASE e.valueType WHEN 'string' THEN toLower(e.value) CONTAINS toLower($term) WHEN 'array' THEN ANY(x IN e.value WHERE toLower(toString(x)) CONTAINS toLower($term)) @@ -201,10 +212,12 @@ export class DbService { >(ids: string[], eName: string): Promise[]> { if (!ids.length) return []; if (!eName) { - throw new Error("eName is required for finding meta-envelopes by IDs"); + throw new Error( + "eName is required for finding meta-envelopes by IDs", + ); } - const result = await this.runQuery( + const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope) WHERE m.id IN $ids @@ -257,10 +270,12 @@ export class DbService { T extends Record = Record, >(id: string, eName: string): Promise | null> { if (!eName) { - throw new Error("eName is required for finding meta-envelopes by ID"); + throw new Error( + "eName is required for finding meta-envelopes by ID", + ); } - const result = await this.runQuery( + const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { id: $id, eName: $eName })-[:LINKS_TO]->(e:Envelope) RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes @@ -313,10 +328,12 @@ export class DbService { T extends Record = Record, >(ontology: string, eName: string): Promise[]> { if (!eName) { - throw new Error("eName is required for finding meta-envelopes by ontology"); + throw new Error( + "eName is required for finding meta-envelopes by ontology", + ); } - const result = await this.runQuery( + const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { ontology: $ontology, eName: $eName })-[:LINKS_TO]->(e:Envelope) RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes @@ -368,7 +385,7 @@ export class DbService { throw new Error("eName is required for deleting meta-envelopes"); } - await this.runQuery( + await this.runQueryInternal( ` MATCH (m:MetaEnvelope { id: $id, eName: $eName })-[:LINKS_TO]->(e:Envelope) DETACH DELETE m, e @@ -396,7 +413,7 @@ export class DbService { serializeValue(newValue); // First verify the envelope belongs to a meta-envelope with the correct eName - await this.runQuery( + await this.runQueryInternal( ` MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope { id: $envelopeId }) SET e.value = $newValue, e.valueType = $valueType @@ -429,7 +446,7 @@ export class DbService { let existing = await this.findMetaEnvelopeById(id, eName); if (!existing) { const metaW3id = await new W3IDBuilder().build(); - await this.runQuery( + await this.runQueryInternal( ` CREATE (m:MetaEnvelope { id: $id, @@ -438,18 +455,24 @@ export class DbService { eName: $eName }) `, - { id, ontology: meta.ontology, acl, eName } + { id, ontology: meta.ontology, acl, eName }, ); - existing = { id, ontology: meta.ontology, acl, parsed: meta.payload, envelopes: [] }; + existing = { + id, + ontology: meta.ontology, + acl, + parsed: meta.payload, + envelopes: [], + }; } // Update the meta-envelope properties (ensure eName matches) - await this.runQuery( + await this.runQueryInternal( ` MATCH (m:MetaEnvelope { id: $id, eName: $eName }) SET m.ontology = $ontology, m.acl = $acl `, - { id, ontology: meta.ontology, acl, eName } + { id, ontology: meta.ontology, acl, eName }, ); const createdEnvelopes: Envelope[] = []; @@ -458,15 +481,18 @@ export class DbService { // For each field in the new payload for (const [key, value] of Object.entries(meta.payload)) { try { - const { value: storedValue, type: valueType } = serializeValue(value); + const { value: storedValue, type: valueType } = + serializeValue(value); const alias = `e${counter}`; // Check if an envelope with this ontology already exists - const existingEnvelope = existing.envelopes.find(e => e.ontology === key); + const existingEnvelope = existing.envelopes.find( + (e) => e.ontology === key, + ); if (existingEnvelope) { // Update existing envelope - await this.runQuery( + await this.runQueryInternal( ` MATCH (e:Envelope { id: $envelopeId }) SET e.value = $newValue, e.valueType = $valueType @@ -475,7 +501,7 @@ export class DbService { envelopeId: existingEnvelope.id, newValue: storedValue, valueType, - } + }, ); createdEnvelopes.push({ @@ -489,7 +515,7 @@ export class DbService { const envW3id = await new W3IDBuilder().build(); const envelopeId = envW3id.id; - await this.runQuery( + await this.runQueryInternal( ` MATCH (m:MetaEnvelope { id: $metaId, eName: $eName }) CREATE (${alias}:Envelope { @@ -508,7 +534,7 @@ export class DbService { [`${alias}_ontology`]: key, [`${alias}_value`]: storedValue, [`${alias}_type`]: valueType, - } + }, ); createdEnvelopes.push({ @@ -529,20 +555,23 @@ export class DbService { // Delete envelopes that are no longer in the payload const existingOntologies = new Set(Object.keys(meta.payload)); const envelopesToDelete = existing.envelopes.filter( - e => !existingOntologies.has(e.ontology) + (e) => !existingOntologies.has(e.ontology), ); for (const envelope of envelopesToDelete) { try { - await this.runQuery( + await this.runQueryInternal( ` MATCH (e:Envelope { id: $envelopeId }) DETACH DELETE e `, - { envelopeId: envelope.id } + { envelopeId: envelope.id }, ); } catch (error) { - console.error(`Error deleting envelope ${envelope.id}:`, error); + console.error( + `Error deleting envelope ${envelope.id}:`, + error, + ); throw error; } } @@ -556,24 +585,80 @@ export class DbService { envelopes: createdEnvelopes, }; } catch (error) { - console.error('Error in updateMetaEnvelopeById:', error); + console.error("Error in updateMetaEnvelopeById:", error); throw error; } } + /** + * Finds all meta-envelopes for a specific eName, regardless of ontology. + * @param eName - The eName identifier for multi-tenant isolation + * @returns Array of all meta-envelopes with their envelopes and parsed payload + */ + async findAllMetaEnvelopesByEName< + T extends Record = Record, + >(eName: string): Promise[]> { + if (!eName) { + throw new Error("eName is required for finding all meta-envelopes"); + } + + const result = await this.runQueryInternal( + ` + MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope) + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes + `, + { eName }, + ); + + return result.records.map((record): MetaEnvelopeResult => { + const envelopes = record + .get("envelopes") + .map((node: any): Envelope => { + const properties = node.properties; + return { + id: properties.id, + ontology: properties.ontology, + value: deserializeValue( + properties.value, + properties.valueType, + ) as T[keyof T], + valueType: properties.valueType, + }; + }); + + const parsed = envelopes.reduce( + (acc: T, envelope: Envelope) => { + (acc as any)[envelope.ontology] = envelope.value; + return acc; + }, + {} as T, + ); + + return { + id: record.get("id"), + ontology: record.get("ontology"), + acl: record.get("acl"), + envelopes, + parsed, + }; + }); + } + /** * Retrieves all envelopes for a specific eName. * @param eName - The eName identifier for multi-tenant isolation * @returns Array of all envelopes for the given eName */ - async getAllEnvelopes(eName: string): Promise> { + async getAllEnvelopes( + eName: string, + ): Promise> { if (!eName) { throw new Error("eName is required for getting all envelopes"); } - const result = await this.runQuery( + const result = await this.runQueryInternal( `MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope) RETURN e`, - { eName } + { eName }, ); return result.records.map((r): Envelope => { const node = r.get("e"); @@ -600,9 +685,9 @@ export class DbService { throw new Error("eName is required for getting public key"); } - const result = await this.runQuery( + const result = await this.runQueryInternal( `MATCH (u:User { eName: $eName }) RETURN u.publicKey AS publicKey`, - { eName } + { eName }, ); if (!result.records[0]) { @@ -625,11 +710,172 @@ export class DbService { throw new Error("publicKey is required"); } - await this.runQuery( + await this.runQueryInternal( `MERGE (u:User { eName: $eName }) SET u.publicKey = $publicKey`, - { eName, publicKey } + { eName, publicKey }, + ); + } + + /** + * Copies all meta-envelopes and their envelopes from this evault to a target evault instance. + * Preserves all IDs (metaEnvelope.id, envelope.id) and the eName property. + * This bypasses GraphQL resolvers, so no webhooks are triggered. + * @param eName - The eName identifier for the meta-envelopes to copy + * @param targetDbService - The DbService instance for the target evault + * @returns The count of meta-envelopes copied + */ + async copyMetaEnvelopesToNewEvaultInstance( + eName: string, + targetDbService: DbService, + ): Promise { + if (!eName) { + throw new Error("eName is required for copying meta-envelopes"); + } + if (!targetDbService) { + throw new Error("targetDbService is required"); + } + + console.log( + `[MIGRATION] Starting copy of metaEnvelopes for eName: ${eName}`, + ); + + // Get all meta-envelopes for this eName + const metaEnvelopes = await this.findAllMetaEnvelopesByEName(eName); + const count = metaEnvelopes.length; + + if (count === 0) { + console.log( + `[MIGRATION] No metaEnvelopes found for eName: ${eName}`, + ); + return 0; + } + + console.log( + `[MIGRATION] Found ${count} metaEnvelopes to copy for eName: ${eName}`, ); + + // Copy each meta-envelope to the target evault + for (const metaEnvelope of metaEnvelopes) { + // Create the meta-envelope in target evault with same ID and eName + await targetDbService.runQuery( + ` + MERGE (m:MetaEnvelope { id: $metaId, eName: $eName }) + SET m.ontology = $ontology, m.acl = $acl + `, + { + metaId: metaEnvelope.id, + ontology: metaEnvelope.ontology, + acl: metaEnvelope.acl, + eName: eName, + }, + ); + + // Copy all envelopes for this meta-envelope + for (const envelope of metaEnvelope.envelopes) { + const { value: storedValue, type: valueType } = serializeValue( + envelope.value, + ); + + // Ensure value and valueType are explicitly null if undefined (Neo4j requires explicit null) + const valueParam = storedValue !== undefined ? storedValue : null; + const valueTypeParam = valueType !== undefined ? valueType : null; + + await targetDbService.runQuery( + ` + MERGE (e:Envelope { id: $envelopeId }) + SET e.ontology = $ontology, + e.value = $value, + e.valueType = $valueType + WITH e + MATCH (m:MetaEnvelope { id: $metaId, eName: $eName }) + MERGE (m)-[:LINKS_TO]->(e) + `, + { + envelopeId: envelope.id, + ontology: envelope.ontology, + value: valueParam, + valueType: valueTypeParam, + metaId: metaEnvelope.id, + eName: eName, + }, + ); + } + } + + // Copy User node with public key if it exists + try { + const userResult = await this.runQueryInternal( + `MATCH (u:User { eName: $eName }) RETURN u.publicKey AS publicKey`, + { eName }, + ); + + if (userResult.records.length > 0) { + const publicKey = userResult.records[0].get("publicKey"); + if (publicKey) { + console.log( + `[MIGRATION] Copying User node with public key for eName: ${eName}`, + ); + await targetDbService.runQuery( + `MERGE (u:User { eName: $eName }) + SET u.publicKey = $publicKey`, + { eName, publicKey }, + ); + console.log( + `[MIGRATION] User node with public key copied successfully`, + ); + } + } + } catch (error) { + console.error(`[MIGRATION ERROR] Failed to copy User node:`, error); + // Don't fail the migration if User node copy fails + } + + // Verify envelope relationships for each metaEnvelope + console.log( + `[MIGRATION] Verifying envelope relationships for ${count} metaEnvelopes`, + ); + for (const metaEnvelope of metaEnvelopes) { + // Get envelope IDs from target + const targetEnvelopesResult = await targetDbService.runQuery( + ` + MATCH (m:MetaEnvelope { id: $metaId, eName: $eName })-[:LINKS_TO]->(e:Envelope) + RETURN collect(e.id) AS envelopeIds + `, + { metaId: metaEnvelope.id, eName }, + ); + + const targetEnvelopeIds = new Set( + targetEnvelopesResult.records[0]?.get("envelopeIds") || [], + ); + const sourceEnvelopeIds = new Set( + metaEnvelope.envelopes.map((e) => e.id), + ); + + if (targetEnvelopeIds.size !== sourceEnvelopeIds.size) { + throw new Error( + `Envelope count mismatch for metaEnvelope ${metaEnvelope.id}: expected ${sourceEnvelopeIds.size}, got ${targetEnvelopeIds.size}`, + ); + } + + for (const envelopeId of sourceEnvelopeIds) { + if (!targetEnvelopeIds.has(envelopeId)) { + throw new Error( + `Missing envelope ${envelopeId} for metaEnvelope ${metaEnvelope.id}`, + ); + } + } + + console.log( + `[MIGRATION] Verified ${sourceEnvelopeIds.size} envelopes for metaEnvelope ${metaEnvelope.id}`, + ); + } + + console.log( + `[MIGRATION] Successfully copied and verified ${count} metaEnvelopes with all envelopes for eName: ${eName}`, + ); + + return count; } /** diff --git a/infrastructure/evault-core/src/core/http/server.ts b/infrastructure/evault-core/src/core/http/server.ts index 898901de..2ab81785 100644 --- a/infrastructure/evault-core/src/core/http/server.ts +++ b/infrastructure/evault-core/src/core/http/server.ts @@ -1,116 +1,126 @@ -import fastify, { FastifyInstance } from "fastify"; import swagger from "@fastify/swagger"; import swaggerUi from "@fastify/swagger-ui"; -import { WatcherRequest, TypedRequest, TypedReply } from "./types"; -import { ProvisioningService, ProvisionRequest } from "../../services/ProvisioningService"; -import { DbService } from "../db/db.service"; -import * as jose from "jose"; import axios from "axios"; +import fastify, { type FastifyInstance } from "fastify"; +import * as jose from "jose"; +import type { + ProvisionRequest, + ProvisioningService, +} from "../../services/ProvisioningService"; +import { DbService } from "../db/db.service"; +import { connectWithRetry } from "../db/retry-neo4j"; +import { type TypedReply, type TypedRequest, WatcherRequest } from "./types"; interface WatcherSignatureRequest { - w3id: string; - logEntryId: string; - proof: { - signature: string; - alg: string; - kid: string; - }; + w3id: string; + logEntryId: string; + proof: { + signature: string; + alg: string; + kid: string; + }; } export async function registerHttpRoutes( - server: FastifyInstance, - evault: any, // EVault instance to access publicKey - provisioningService?: ProvisioningService, - dbService?: DbService + server: FastifyInstance, + evault: any, // EVault instance to access publicKey + provisioningService?: ProvisioningService, + dbService?: DbService, ): Promise { - // Register Swagger - await server.register(swagger, { - swagger: { - info: { - title: "eVault Core API", - description: "API documentation for eVault Core HTTP endpoints", - version: "1.0.0", - }, - tags: [ - { name: "identity", description: "Identity related endpoints" }, - { - name: "watchers", - description: "Watcher signature related endpoints", - }, - { - name: "provisioning", - description: "eVault provisioning endpoints", + // Register Swagger + await server.register(swagger, { + swagger: { + info: { + title: "eVault Core API", + description: "API documentation for eVault Core HTTP endpoints", + version: "1.0.0", + }, + tags: [ + { name: "identity", description: "Identity related endpoints" }, + { + name: "watchers", + description: "Watcher signature related endpoints", + }, + { + name: "provisioning", + description: "eVault provisioning endpoints", + }, + ], }, - ], - }, - }); + }); - await server.register(swaggerUi, { - routePrefix: "/docs", - }); + await server.register(swaggerUi, { + routePrefix: "/docs", + }); - // Whois endpoint - returns both W3ID identifier and public key - server.get( - "/whois", - { - schema: { - tags: ["identity"], - description: "Get eVault W3ID identifier and public key", - headers: { - type: "object", - required: ["X-ENAME"], - properties: { - "X-ENAME": { type: "string" }, - }, - }, - response: { - 200: { - type: "object", - properties: { - w3id: { type: "string" }, - publicKey: { type: "string", nullable: true }, - }, - }, - 400: { - type: "object", - properties: { - error: { type: "string" }, + // Whois endpoint - returns both W3ID identifier and public key + server.get( + "/whois", + { + schema: { + tags: ["identity"], + description: "Get eVault W3ID identifier and public key", + headers: { + type: "object", + required: ["X-ENAME"], + properties: { + "X-ENAME": { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + w3id: { type: "string" }, + publicKey: { type: "string", nullable: true }, + }, + }, + 400: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, }, - }, }, - }, - }, - async (request: TypedRequest<{}>, reply: TypedReply) => { - const eName = request.headers["x-ename"] || request.headers["X-ENAME"]; - - if (!eName || typeof eName !== "string") { - return reply.status(400).send({ error: "X-ENAME header is required" }); - } - - // Get public key from database if dbService is available - let publicKey: string | null = null; - if (dbService) { - try { - publicKey = await dbService.getPublicKey(eName); - } catch (error) { - console.error("Error getting public key from database:", error); - // Continue with null publicKey - } - } - - const result = { - w3id: eName, - publicKey: publicKey, - }; - console.log("Whois request:", result); - return result; - } - ); + async (request: TypedRequest<{}>, reply: TypedReply) => { + const eName = + request.headers["x-ename"] || request.headers["X-ENAME"]; + + if (!eName || typeof eName !== "string") { + return reply + .status(400) + .send({ error: "X-ENAME header is required" }); + } + + // Get public key from database if dbService is available + let publicKey: string | null = null; + if (dbService) { + try { + publicKey = await dbService.getPublicKey(eName); + } catch (error) { + console.error( + "Error getting public key from database:", + error, + ); + // Continue with null publicKey + } + } + + const result = { + w3id: eName, + publicKey: publicKey, + }; + console.log("Whois request:", result); + return result; + }, + ); - // Watchers signature endpoint - DISABLED: Requires W3ID functionality - // This endpoint is disabled because the eVault no longer creates/manages W3IDs - // The private key is now stored on the user's phone - /* + // Watchers signature endpoint - DISABLED: Requires W3ID functionality + // This endpoint is disabled because the eVault no longer creates/manages W3IDs + // The private key is now stored on the user's phone + /* server.post<{ Body: WatcherSignatureRequest }>( "/watchers/sign", { @@ -199,10 +209,10 @@ export async function registerHttpRoutes( ); */ - // Watchers request endpoint - DISABLED: Requires W3ID functionality - // This endpoint is disabled because the eVault no longer creates/manages W3IDs - // The private key is now stored on the user's phone - /* + // Watchers request endpoint - DISABLED: Requires W3ID functionality + // This endpoint is disabled because the eVault no longer creates/manages W3IDs + // The private key is now stored on the user's phone + /* server.post<{ Body: WatcherRequest }>( "/watchers/request", { @@ -283,181 +293,456 @@ export async function registerHttpRoutes( ); */ - // Helper function to validate JWT token - async function validateToken(authHeader: string | null): Promise { - if (!authHeader || !authHeader.startsWith("Bearer ")) { - console.error("Token validation: Missing or invalid Authorization header format"); - return null; - } - - const token = authHeader.substring(7); // Remove 'Bearer ' prefix + // Helper function to validate JWT token + async function validateToken( + authHeader: string | null, + ): Promise { + if (!authHeader || !authHeader.startsWith("Bearer ")) { + console.error( + "Token validation: Missing or invalid Authorization header format", + ); + return null; + } - try { - // Try REGISTRY_URL first, fallback to PUBLIC_REGISTRY_URL - const registryUrl = process.env.REGISTRY_URL || process.env.PUBLIC_REGISTRY_URL; - if (!registryUrl) { - console.error("Token validation: REGISTRY_URL or PUBLIC_REGISTRY_URL is not set"); - return null; - } + const token = authHeader.substring(7); // Remove 'Bearer ' prefix - const jwksUrl = new URL(`/.well-known/jwks.json`, registryUrl).toString(); - console.log(`Token validation: Fetching JWKS from ${jwksUrl}`); - - const jwksResponse = await axios.get(jwksUrl, { - timeout: 5000, - }); - - console.log(`Token validation: JWKS response keys count: ${jwksResponse.data?.keys?.length || 0}`); - - const JWKS = jose.createLocalJWKSet(jwksResponse.data); - - // Decode token header to see what kid it's using - const decodedHeader = jose.decodeProtectedHeader(token); - console.log(`Token validation: Token header - alg: ${decodedHeader.alg}, kid: ${decodedHeader.kid}`); - - const { payload } = await jose.jwtVerify(token, JWKS); - - console.log(`Token validation: Token verified successfully, payload:`, payload); - return payload; - } catch (error: any) { - console.error("Token validation failed:", error.message || error); - if (error.code) { - console.error(`Token validation error code: ${error.code}`); - } - if (error.response) { - console.error(`Token validation HTTP error: ${error.response.status} - ${error.response.statusText}`); - } - if (error.cause) { - console.error(`Token validation error cause:`, error.cause); - } - return null; + try { + // Try REGISTRY_URL first, fallback to PUBLIC_REGISTRY_URL + const registryUrl = + process.env.REGISTRY_URL || process.env.PUBLIC_REGISTRY_URL; + if (!registryUrl) { + console.error( + "Token validation: REGISTRY_URL or PUBLIC_REGISTRY_URL is not set", + ); + return null; + } + + const jwksUrl = new URL( + "/.well-known/jwks.json", + registryUrl, + ).toString(); + console.log(`Token validation: Fetching JWKS from ${jwksUrl}`); + + const jwksResponse = await axios.get(jwksUrl, { + timeout: 5000, + }); + + console.log( + `Token validation: JWKS response keys count: ${jwksResponse.data?.keys?.length || 0}`, + ); + + const JWKS = jose.createLocalJWKSet(jwksResponse.data); + + // Decode token header to see what kid it's using + const decodedHeader = jose.decodeProtectedHeader(token); + console.log( + `Token validation: Token header - alg: ${decodedHeader.alg}, kid: ${decodedHeader.kid}`, + ); + + const { payload } = await jose.jwtVerify(token, JWKS); + + console.log( + "Token validation: Token verified successfully, payload:", + payload, + ); + return payload; + } catch (error: any) { + console.error("Token validation failed:", error.message || error); + if (error.code) { + console.error(`Token validation error code: ${error.code}`); + } + if (error.response) { + console.error( + `Token validation HTTP error: ${error.response.status} - ${error.response.statusText}`, + ); + } + if (error.cause) { + console.error("Token validation error cause:", error.cause); + } + return null; + } } - } - // PATCH endpoint to save public key - server.patch<{ Body: { publicKey: string } }>( - "/public-key", - { - schema: { - tags: ["identity"], - description: "Save public key for a user's eName", - headers: { - type: "object", - required: ["X-ENAME", "Authorization"], - properties: { - "X-ENAME": { type: "string" }, - "Authorization": { type: "string" }, - }, + // PATCH endpoint to save public key + server.patch<{ Body: { publicKey: string } }>( + "/public-key", + { + schema: { + tags: ["identity"], + description: "Save public key for a user's eName", + headers: { + type: "object", + required: ["X-ENAME", "Authorization"], + properties: { + "X-ENAME": { type: "string" }, + Authorization: { type: "string" }, + }, + }, + body: { + type: "object", + required: ["publicKey"], + properties: { + publicKey: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 400: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + 401: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, + }, }, - body: { - type: "object", - required: ["publicKey"], - properties: { - publicKey: { type: "string" }, - }, + async ( + request: TypedRequest<{ publicKey: string }>, + reply: TypedReply, + ) => { + const eName = + request.headers["x-ename"] || request.headers["X-ENAME"]; + + if (!eName || typeof eName !== "string") { + return reply + .status(400) + .send({ error: "X-ENAME header is required" }); + } + + const authHeader = + request.headers.authorization || request.headers.Authorization; + const tokenPayload = await validateToken( + typeof authHeader === "string" ? authHeader : null, + ); + + if (!tokenPayload) { + return reply + .status(401) + .send({ error: "Invalid or missing authentication token" }); + } + + const { publicKey } = request.body; + if (!publicKey) { + return reply + .status(400) + .send({ error: "publicKey is required in request body" }); + } + + if (!dbService) { + return reply + .status(500) + .send({ error: "Database service not available" }); + } + + try { + await dbService.setPublicKey(eName, publicKey); + return { + success: true, + message: "Public key saved successfully", + }; + } catch (error) { + console.error("Error saving public key:", error); + return reply.status(500).send({ + error: + error instanceof Error + ? error.message + : "Failed to save public key", + }); + } }, - response: { - 200: { - type: "object", - properties: { - success: { type: "boolean" }, - message: { type: "string" }, - }, - }, - 400: { - type: "object", - properties: { - error: { type: "string" }, + ); + + // Provision eVault endpoint + if (provisioningService) { + server.post<{ Body: ProvisionRequest }>( + "/provision", + { + schema: { + tags: ["provisioning"], + description: + "Provision a new eVault instance (logical only, no infrastructure)", + body: { + type: "object", + required: [ + "registryEntropy", + "namespace", + "verificationId", + "publicKey", + ], + properties: { + registryEntropy: { type: "string" }, + namespace: { type: "string" }, + verificationId: { type: "string" }, + publicKey: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + w3id: { type: "string" }, + uri: { type: "string" }, + message: { type: "string" }, + error: { type: "string" }, + }, + }, + }, + }, }, - }, - 401: { - type: "object", - properties: { - error: { type: "string" }, + async ( + request: TypedRequest, + reply: TypedReply, + ) => { + const result = await provisioningService.provisionEVault( + request.body, + ); + if (!result.success) { + return reply.status(500).send(result); + } + return result; }, - }, - }, - }, - }, - async (request: TypedRequest<{ publicKey: string }>, reply: TypedReply) => { - const eName = request.headers["x-ename"] || request.headers["X-ENAME"]; - - if (!eName || typeof eName !== "string") { - return reply.status(400).send({ error: "X-ENAME header is required" }); - } - - const authHeader = request.headers.authorization || request.headers["Authorization"]; - const tokenPayload = await validateToken( - typeof authHeader === "string" ? authHeader : null - ); - - if (!tokenPayload) { - return reply.status(401).send({ error: "Invalid or missing authentication token" }); - } - - const { publicKey } = request.body; - if (!publicKey) { - return reply.status(400).send({ error: "publicKey is required in request body" }); - } - - if (!dbService) { - return reply.status(500).send({ error: "Database service not available" }); - } - - try { - await dbService.setPublicKey(eName, publicKey); - return { - success: true, - message: "Public key saved successfully", - }; - } catch (error) { - console.error("Error saving public key:", error); - return reply.status(500).send({ - error: error instanceof Error ? error.message : "Failed to save public key", - }); - } + ); } - ); - // Provision eVault endpoint - if (provisioningService) { - server.post<{ Body: ProvisionRequest }>( - "/provision", - { - schema: { - tags: ["provisioning"], - description: "Provision a new eVault instance (logical only, no infrastructure)", - body: { - type: "object", - required: ["registryEntropy", "namespace", "verificationId", "publicKey"], - properties: { - registryEntropy: { type: "string" }, - namespace: { type: "string" }, - verificationId: { type: "string" }, - publicKey: { type: "string" }, - }, - }, - response: { - 200: { - type: "object", - properties: { - success: { type: "boolean" }, - w3id: { type: "string" }, - uri: { type: "string" }, - message: { type: "string" }, - error: { type: "string" }, - }, + // Emover endpoint - Copy metaEnvelopes to new evault instance + server.post<{ + Body: { + eName: string; + targetNeo4jUri: string; + targetNeo4jUser: string; + targetNeo4jPassword: string; + }; + }>( + "/emover", + { + schema: { + tags: ["migration"], + description: + "Copy all metaEnvelopes for an eName to a new evault instance", + body: { + type: "object", + required: [ + "eName", + "targetNeo4jUri", + "targetNeo4jUser", + "targetNeo4jPassword", + ], + properties: { + eName: { type: "string" }, + targetNeo4jUri: { type: "string" }, + targetNeo4jUser: { type: "string" }, + targetNeo4jPassword: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + message: { type: "string" }, + }, + }, + 400: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, }, - }, }, - }, - async (request: TypedRequest, reply: TypedReply) => { - const result = await provisioningService.provisionEVault(request.body); - if (!result.success) { - return reply.status(500).send(result); - } - return result; - } + async ( + request: TypedRequest<{ + eName: string; + targetNeo4jUri: string; + targetNeo4jUser: string; + targetNeo4jPassword: string; + }>, + reply: TypedReply, + ) => { + const { + eName, + targetNeo4jUri, + targetNeo4jUser, + targetNeo4jPassword, + } = request.body; + + if (!dbService) { + return reply.status(500).send({ + error: "Database service not available", + }); + } + + try { + console.log( + `[MIGRATION] Starting migration for eName: ${eName} to target evault`, + ); + + // Step 1: Validate eName exists in current evault + const existingMetaEnvelopes = + await dbService.findAllMetaEnvelopesByEName(eName); + if (existingMetaEnvelopes.length === 0) { + console.log( + `[MIGRATION] No metaEnvelopes found for eName: ${eName}`, + ); + return reply.status(400).send({ + error: `No metaEnvelopes found for eName: ${eName}`, + }); + } + + console.log( + `[MIGRATION] Found ${existingMetaEnvelopes.length} metaEnvelopes for eName: ${eName}`, + ); + + // Step 2: Create connection to target evault's Neo4j + console.log( + `[MIGRATION] Connecting to target Neo4j at: ${targetNeo4jUri}`, + ); + const targetDriver = await connectWithRetry( + targetNeo4jUri, + targetNeo4jUser, + targetNeo4jPassword, + ); + const targetDbService = new DbService(targetDriver); + + try { + // Step 3: Copy all metaEnvelopes to target evault + console.log( + `[MIGRATION] Copying ${existingMetaEnvelopes.length} metaEnvelopes to target evault`, + ); + const copiedCount = + await dbService.copyMetaEnvelopesToNewEvaultInstance( + eName, + targetDbService, + ); + + // Step 4: Verify copy + console.log( + `[MIGRATION] Verifying copy: checking ${copiedCount} metaEnvelopes in target evault`, + ); + const targetMetaEnvelopes = + await targetDbService.findAllMetaEnvelopesByEName( + eName, + ); + + if ( + targetMetaEnvelopes.length !== + existingMetaEnvelopes.length + ) { + const error = `Copy verification failed: expected ${existingMetaEnvelopes.length} metaEnvelopes, found ${targetMetaEnvelopes.length}`; + console.error(`[MIGRATION ERROR] ${error}`); + return reply.status(500).send({ error }); + } + + // Verify IDs match + const sourceIds = new Set( + existingMetaEnvelopes.map((m) => m.id), + ); + const targetIds = new Set( + targetMetaEnvelopes.map((m) => m.id), + ); + + if (sourceIds.size !== targetIds.size) { + const error = + "Copy verification failed: ID count mismatch"; + console.error(`[MIGRATION ERROR] ${error}`); + return reply.status(500).send({ error }); + } + + for (const id of sourceIds) { + if (!targetIds.has(id)) { + const error = `Copy verification failed: missing metaEnvelope ID: ${id}`; + console.error(`[MIGRATION ERROR] ${error}`); + return reply.status(500).send({ error }); + } + } + + // Verify envelope relationships for each metaEnvelope + console.log( + `[MIGRATION] Verifying envelope relationships for each metaEnvelope`, + ); + for (const sourceMeta of existingMetaEnvelopes) { + const sourceEnvelopeIds = new Set( + sourceMeta.envelopes.map((e) => e.id), + ); + + const targetMeta = targetMetaEnvelopes.find( + (m) => m.id === sourceMeta.id, + ); + if (!targetMeta) { + const error = `Copy verification failed: missing metaEnvelope ID: ${sourceMeta.id}`; + console.error(`[MIGRATION ERROR] ${error}`); + return reply.status(500).send({ error }); + } + + const targetEnvelopeIds = new Set( + targetMeta.envelopes.map((e) => e.id), + ); + + if (sourceEnvelopeIds.size !== targetEnvelopeIds.size) { + const error = `Copy verification failed: envelope count mismatch for metaEnvelope ${sourceMeta.id} - expected ${sourceEnvelopeIds.size}, got ${targetEnvelopeIds.size}`; + console.error(`[MIGRATION ERROR] ${error}`); + return reply.status(500).send({ error }); + } + + for (const envelopeId of sourceEnvelopeIds) { + if (!targetEnvelopeIds.has(envelopeId)) { + const error = `Copy verification failed: missing envelope ID ${envelopeId} for metaEnvelope ${sourceMeta.id}`; + console.error(`[MIGRATION ERROR] ${error}`); + return reply.status(500).send({ error }); + } + } + + console.log( + `[MIGRATION] Verified ${sourceEnvelopeIds.size} envelopes for metaEnvelope ${sourceMeta.id}`, + ); + } + + console.log( + `[MIGRATION] Verification successful: ${copiedCount} metaEnvelopes with all envelopes copied and verified`, + ); + + // Close target connection + await targetDriver.close(); + + return { + success: true, + count: copiedCount, + message: `Successfully copied ${copiedCount} metaEnvelopes to target evault`, + }; + } catch (copyError) { + await targetDriver.close(); + throw copyError; + } + } catch (error) { + console.error(`[MIGRATION ERROR] Migration failed:`, error); + return reply.status(500).send({ + error: + error instanceof Error + ? error.message + : "Failed to migrate metaEnvelopes", + }); + } + }, ); - } } diff --git a/platforms/emover-api/README.md b/platforms/emover-api/README.md new file mode 100644 index 00000000..7c35c66b --- /dev/null +++ b/platforms/emover-api/README.md @@ -0,0 +1,77 @@ +# Emover API + +Backend API for the emover platform that handles evault migration operations. + +## Features + +- Secure authentication via eID Wallet (QR code + SSE) +- View current evault host/provider information +- List available provisioners +- Initiate and manage evault migrations +- QR code signing for migration confirmation +- Real-time migration progress via SSE +- Comprehensive logging at every step + +## Environment Variables + +- `PORT` - Server port (default: 4003) +- `PUBLIC_EMOVER_BASE_URL` - API base URL for authentication redirects (default: http://localhost:4003) +- `PUBLIC_REGISTRY_URL` - Registry service URL +- `PROVISIONER_URL` or `PROVISIONER_URLS` - Provisioner URL(s) +- `EVAULT_BASE_URI` - Base URI for evault instances +- `EMOVER_DATABASE_URL` or `DATABASE_URL` - PostgreSQL connection string +- `JWT_SECRET` - Secret for JWT token signing +- `REGISTRY_SHARED_SECRET` - Secret for registry API authentication +- `NEO4J_USER` - Neo4j username (for cross-evault operations) +- `NEO4J_PASSWORD` - Neo4j password (for cross-evault operations) +- `DEMO_VERIFICATION_CODE` - Demo verification code for provisioning + +## API Endpoints + +### Authentication +- `GET /api/auth/offer` - Get QR code for login +- `POST /api/auth` - Handle eID Wallet callback +- `GET /api/auth/sessions/:id` - SSE stream for auth status + +### User +- `GET /api/users/me` - Get current user (protected) + +### Evault Info +- `GET /api/evault/current` - Get current evault info (protected) +- `GET /api/provisioners` - List available provisioners (protected) + +### Migration +- `POST /api/migration/initiate` - Start migration (protected) +- `POST /api/migration/sign` - Create signing session (protected) +- `GET /api/migration/sessions/:id` - SSE stream for migration status +- `POST /api/migration/callback` - Handle signed payload from eID Wallet +- `GET /api/migration/status/:id` - Get migration status +- `POST /api/migration/delete-old` - Delete old evault (protected) + +## Migration Flow + +1. User initiates migration with selected provisioner +2. System provisions new evault instance +3. **Copies all metaEnvelopes to new evault** (preserving IDs and eName) +4. **Verifies copy** (count, IDs, integrity) +5. **Updates registry mapping** (only after successful verification) +6. **Verifies registry update** +7. **Marks new evault as active** +8. **Verifies new evault is working** +9. **Deletes old evault** (only after all above steps succeed) + +## Database + +Uses PostgreSQL with TypeORM. Run migrations: + +```bash +npm run migration:run +``` + +## Development + +```bash +npm install +npm run dev +``` + diff --git a/platforms/emover-api/package.json b/platforms/emover-api/package.json new file mode 100644 index 00000000..4d7c5929 --- /dev/null +++ b/platforms/emover-api/package.json @@ -0,0 +1,38 @@ +{ + "name": "emover-api", + "version": "1.0.0", + "description": "Emover Platform API for evault migration", + "main": "src/index.ts", + "scripts": { + "start": "ts-node --project tsconfig.json src/index.ts", + "dev": "nodemon --exec \"npx ts-node\" src/index.ts", + "build": "tsc", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "typeorm-ts-node-commonjs migration:generate src/database/migrations/migration -d src/database/data-source.ts", + "migration:run": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts", + "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts" + }, + "dependencies": { + "axios": "^1.6.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.1", + "typeorm": "^0.3.24", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.11.24", + "@types/pg": "^8.11.2", + "@types/uuid": "^9.0.8", + "nodemon": "^3.0.3", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} + diff --git a/platforms/emover-api/src/controllers/AuthController.ts b/platforms/emover-api/src/controllers/AuthController.ts new file mode 100644 index 00000000..1e8f95e1 --- /dev/null +++ b/platforms/emover-api/src/controllers/AuthController.ts @@ -0,0 +1,114 @@ +import { EventEmitter } from "events"; +import type { Request, Response } from "express"; +import { v4 as uuidv4 } from "uuid"; +import { UserService } from "../services/UserService"; +import { signToken } from "../utils/jwt"; +import { isVersionValid } from "../utils/version"; + +const MIN_REQUIRED_VERSION = "0.4.0"; + +export class AuthController { + private userService: UserService; + private eventEmitter: EventEmitter; + + constructor() { + this.userService = new UserService(); + this.eventEmitter = new EventEmitter(); + } + + sseStream = async (req: Request, res: Response) => { + const { id } = req.params; + + // Set headers for SSE + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const handler = (data: unknown) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + this.eventEmitter.on(id, handler); + + // Handle client disconnect + req.on("close", () => { + this.eventEmitter.off(id, handler); + res.end(); + }); + + req.on("error", (error) => { + console.error("SSE Error:", error); + this.eventEmitter.off(id, handler); + res.end(); + }); + }; + + getOffer = async (req: Request, res: Response) => { + const baseUrl = + process.env.PUBLIC_EMOVER_BASE_URL || "http://localhost:4003"; + const url = new URL("/api/auth", baseUrl).toString(); + const session = uuidv4(); + const offer = `w3ds://auth?redirect=${url}&session=${session}&platform=emover`; + res.json({ uri: offer }); + }; + + login = async (req: Request, res: Response) => { + try { + const { ename, session, appVersion } = req.body; + + console.log(ename, session, appVersion); + + if (!ename) { + return res.status(400).json({ error: "ename is required" }); + } + + if (!session) { + return res.status(400).json({ error: "session is required" }); + } + + // Check app version + if ( + !appVersion || + !isVersionValid(appVersion, MIN_REQUIRED_VERSION) + ) { + const errorMessage = { + error: true, + message: `Your eID Wallet app version is outdated. Please update to version ${MIN_REQUIRED_VERSION} or later.`, + type: "version_mismatch", + }; + this.eventEmitter.emit(session, errorMessage); + return res.status(400).json({ + error: "App version too old", + message: errorMessage.message, + }); + } + + // Find user by ename + let user = await this.userService.findByEname(ename); + + if (!user) { + // Create user if doesn't exist + user = await this.userService.createUser(ename); + } + + // Generate token + const token = signToken({ userId: user.id }); + + const data = { + user: { + id: user.id, + ename: user.ename, + }, + token, + }; + this.eventEmitter.emit(session, data); + res.status(200).send(); + } catch (error) { + console.error("Error during login:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/emover-api/src/controllers/EvaultInfoController.ts b/platforms/emover-api/src/controllers/EvaultInfoController.ts new file mode 100644 index 00000000..41c24cc7 --- /dev/null +++ b/platforms/emover-api/src/controllers/EvaultInfoController.ts @@ -0,0 +1,92 @@ +import axios from "axios"; +/// +import type { Request, Response } from "express"; + +export class EvaultInfoController { + private registryUrl: string; + + constructor() { + this.registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:4321"; + } + + getCurrent = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res + .status(401) + .json({ error: "Authentication required" }); + } + + const eName = req.user.ename; + if (!eName) { + return res.status(400).json({ error: "User eName not found" }); + } + + // Query registry for current evault info + const response = await axios.get( + new URL(`/resolve?w3id=${eName}`, this.registryUrl).toString(), + ); + + const evaultInfo = response.data; + + return res.json({ + eName: evaultInfo.ename, + uri: evaultInfo.uri, + evault: evaultInfo.evault, + provider: this.extractProviderFromUri(evaultInfo.uri), + }); + } catch (error) { + console.error("Error getting current evault info:", error); + if (axios.isAxiosError(error) && error.response?.status === 404) { + return res + .status(404) + .json({ error: "Evault not found in registry" }); + } + return res.status(500).json({ error: "Internal server error" }); + } + }; + + getProvisioners = async (req: Request, res: Response) => { + try { + // Read provisioner URLs from environment + const provisionerUrl = process.env.PROVISIONER_URL; + const provisionerUrls = process.env.PROVISIONER_URLS; + + const urls: string[] = []; + + if (provisionerUrl) { + urls.push(provisionerUrl); + } + + if (provisionerUrls) { + urls.push( + ...provisionerUrls.split(",").map((url) => url.trim()), + ); + } + + // Remove duplicates + const uniqueUrls = [...new Set(urls)]; + + const provisioners = uniqueUrls.map((url) => ({ + url, + name: this.extractProviderFromUri(url), + description: `Provisioner at ${url}`, + })); + + return res.json(provisioners); + } catch (error) { + console.error("Error getting provisioners:", error); + return res.status(500).json({ error: "Internal server error" }); + } + }; + + private extractProviderFromUri(uri: string): string { + try { + const url = new URL(uri); + return url.hostname || uri; + } catch { + return uri; + } + } +} diff --git a/platforms/emover-api/src/controllers/MigrationController.ts b/platforms/emover-api/src/controllers/MigrationController.ts new file mode 100644 index 00000000..66b1149c --- /dev/null +++ b/platforms/emover-api/src/controllers/MigrationController.ts @@ -0,0 +1,318 @@ +/// +import { EventEmitter } from "node:events"; +import type { Request, Response } from "express"; +import { v4 as uuidv4 } from "uuid"; +import { MigrationService } from "../services/MigrationService"; +import { SigningService } from "../services/SigningService"; + +export class MigrationController { + private migrationService: MigrationService; + private signingService: SigningService; + private eventEmitter: EventEmitter; + + constructor() { + this.migrationService = new MigrationService(); + this.signingService = new SigningService(); + this.eventEmitter = new EventEmitter(); + + // Forward migration service events + this.migrationService.on( + "migration-update", + (migrationId: string, data: unknown) => { + this.eventEmitter.emit(migrationId, data); + }, + ); + } + + initiate = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res + .status(401) + .json({ error: "Authentication required" }); + } + + const { provisionerUrl } = req.body; + if (!provisionerUrl) { + return res + .status(400) + .json({ error: "provisionerUrl is required" }); + } + + const eName = req.user.ename; + if (!eName) { + return res.status(400).json({ error: "User eName not found" }); + } + + const migration = await this.migrationService.initiateMigration( + req.user.id, + eName, + provisionerUrl, + ); + + return res.json({ migrationId: migration.id }); + } catch (error) { + console.error("Error initiating migration:", error); + return res.status(500).json({ + error: + error instanceof Error + ? error.message + : "Internal server error", + }); + } + }; + + sign = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res + .status(401) + .json({ error: "Authentication required" }); + } + + const { migrationId } = req.body; + if (!migrationId) { + return res + .status(400) + .json({ error: "migrationId is required" }); + } + + const migration = + await this.migrationService.getMigrationById(migrationId); + if (!migration) { + return res.status(404).json({ error: "Migration not found" }); + } + + if (migration.userId !== req.user.id) { + return res.status(403).json({ error: "Unauthorized" }); + } + + // Create signing session + const session = await this.signingService.createSession( + migrationId, + { + migrationId, + eName: migration.eName, + newProvisionerUrl: migration.newEvaultUri || "", + }, + ); + + return res.json({ + sessionId: session.id, + qrData: session.qrData, + }); + } catch (error) { + console.error("Error creating signing session:", error); + return res.status(500).json({ error: "Internal server error" }); + } + }; + + getSessionStatus = async (req: Request, res: Response) => { + const { id } = req.params; + + // Set headers for SSE + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const handler = (data: unknown) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + this.eventEmitter.on(id, handler); + + // Handle client disconnect + req.on("close", () => { + this.eventEmitter.off(id, handler); + res.end(); + }); + + req.on("error", (error) => { + console.error("SSE Error:", error); + this.eventEmitter.off(id, handler); + res.end(); + }); + }; + + callback = async (req: Request, res: Response) => { + try { + const { sessionId, signature, w3id, message } = req.body; + + if (!sessionId || !signature || !w3id || !message) { + return res.status(400).json({ + error: "Missing required fields: sessionId, signature, w3id, message", + }); + } + + // Verify signature and process migration + const result = await this.signingService.processSignedPayload( + sessionId, + signature, + w3id, + message, + ); + + if (!result.success) { + return res.status(200).json({ + success: false, + error: result.error, + }); + } + + // Start migration process + const migrationId = result.migrationId; + if (!migrationId) { + return res.status(400).json({ + success: false, + error: "Migration ID not found in signing result", + }); + } + + await this.processMigration(migrationId); + + return res.status(200).json({ + success: true, + message: "Migration started", + migrationId, + }); + } catch (error) { + console.error("Error processing migration callback:", error); + return res.status(500).json({ error: "Internal server error" }); + } + }; + + getStatus = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const migration = await this.migrationService.getMigrationById(id); + if (!migration) { + return res.status(404).json({ error: "Migration not found" }); + } + + return res.json({ + id: migration.id, + status: migration.status, + logs: migration.logs, + error: migration.error, + createdAt: migration.createdAt, + updatedAt: migration.updatedAt, + }); + } catch (error) { + console.error("Error getting migration status:", error); + return res.status(500).json({ error: "Internal server error" }); + } + }; + + deleteOld = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res + .status(401) + .json({ error: "Authentication required" }); + } + + const { migrationId } = req.body; + if (!migrationId) { + return res + .status(400) + .json({ error: "migrationId is required" }); + } + + const migration = + await this.migrationService.getMigrationById(migrationId); + if (!migration) { + return res.status(404).json({ error: "Migration not found" }); + } + + if (migration.userId !== req.user.id) { + return res.status(403).json({ error: "Unauthorized" }); + } + + if (!migration.oldEvaultId) { + return res + .status(400) + .json({ error: "Old evault ID not found" }); + } + + await this.migrationService.deleteOldEvault( + migrationId, + migration.oldEvaultId, + ); + + return res.json({ success: true, message: "Old evault deleted" }); + } catch (error) { + console.error("Error deleting old evault:", error); + return res.status(500).json({ error: "Internal server error" }); + } + }; + + private async processMigration(migrationId: string): Promise { + const migration = + await this.migrationService.getMigrationById(migrationId); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + // Step 1: Provision new evault + if (!migration.provisionerUrl) { + throw new Error("Provisioner URL not found in migration"); + } + + const { evaultId, uri: newEvaultUri, w3id: newW3id } = + await this.migrationService.provisionNewEvault( + migrationId, + migration.provisionerUrl, + migration.eName, + ); + + // Step 2: Copy metaEnvelopes + const count = await this.migrationService.copyMetaEnvelopes( + migrationId, + migration.oldEvaultUri || "", + newEvaultUri, + migration.eName, + ); + + // Step 3: Verify copy + await this.migrationService.verifyDataCopy( + migrationId, + newEvaultUri, + migration.eName, + count, + ); + + // Step 4: Update registry mapping + await this.migrationService.updateRegistryMapping( + migrationId, + migration.eName, + evaultId, + newW3id, + ); + + // Step 5: Verify registry update + await this.migrationService.verifyRegistryUpdate( + migrationId, + migration.eName, + evaultId, + ); + + // Step 6: Mark as active + await this.migrationService.markEvaultActive( + migrationId, + migration.eName, + evaultId, + ); + } catch (error) { + console.error( + `[MIGRATION ERROR] Migration ${migrationId} failed:`, + error, + ); + throw error; + } + } +} diff --git a/platforms/emover-api/src/controllers/UserController.ts b/platforms/emover-api/src/controllers/UserController.ts new file mode 100644 index 00000000..6ff91fed --- /dev/null +++ b/platforms/emover-api/src/controllers/UserController.ts @@ -0,0 +1,24 @@ +/// +import { Request, Response } from "express"; + +export class UserController { + currentUser = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + return res.json({ + id: req.user.id, + ename: req.user.ename, + name: req.user.name, + createdAt: req.user.createdAt, + updatedAt: req.user.updatedAt, + }); + } catch (error) { + console.error("Error getting current user:", error); + return res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/emover-api/src/database/data-source.ts b/platforms/emover-api/src/database/data-source.ts new file mode 100644 index 00000000..097c4cb9 --- /dev/null +++ b/platforms/emover-api/src/database/data-source.ts @@ -0,0 +1,25 @@ +import "reflect-metadata"; +import path from "node:path"; +import { config } from "dotenv"; +import { DataSource, type DataSourceOptions } from "typeorm"; +import { Migration } from "./entities/Migration"; +import { User } from "./entities/User"; + +config({ path: path.resolve(__dirname, "../../../../.env") }); + +export const dataSourceOptions: DataSourceOptions = { + type: "postgres", + url: process.env.EMOVER_DATABASE_URL, + synchronize: false, + entities: [User, Migration], + migrations: [path.join(__dirname, "migrations", "*.ts")], + logging: process.env.NODE_ENV === "development", + ssl: process.env.DB_CA_CERT + ? { + rejectUnauthorized: false, + ca: process.env.DB_CA_CERT, + } + : false, +}; + +export const AppDataSource = new DataSource(dataSourceOptions); diff --git a/platforms/emover-api/src/database/entities/Migration.ts b/platforms/emover-api/src/database/entities/Migration.ts new file mode 100644 index 00000000..e47fe66b --- /dev/null +++ b/platforms/emover-api/src/database/entities/Migration.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +export enum MigrationStatus { + INITIATED = "initiated", + PROVISIONING = "provisioning", + COPYING = "copying", + VERIFYING = "verifying", + UPDATING_REGISTRY = "updating_registry", + MARKING_ACTIVE = "marking_active", + COMPLETED = "completed", + FAILED = "failed", +} + +@Entity("evault_migrations") +export class Migration { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column("uuid") + userId!: string; + + @Column({ nullable: true }) + oldEvaultId!: string; + + @Column({ nullable: true }) + newEvaultId!: string; + + @Column({ nullable: true }) + eName!: string; + + @Column({ nullable: true }) + oldEvaultUri!: string; + + @Column({ nullable: true }) + newEvaultUri!: string; + + @Column({ nullable: true }) + provisionerUrl!: string; + + @Column({ + type: "enum", + enum: MigrationStatus, + default: MigrationStatus.INITIATED, + }) + status!: MigrationStatus; + + @Column("text", { nullable: true }) + logs!: string; + + @Column("text", { nullable: true }) + error!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/emover-api/src/database/entities/User.ts b/platforms/emover-api/src/database/entities/User.ts new file mode 100644 index 00000000..7a77d62c --- /dev/null +++ b/platforms/emover-api/src/database/entities/User.ts @@ -0,0 +1,26 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("users") +export class User { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true }) + ename!: string; + + @Column({ nullable: true }) + name!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/emover-api/src/database/migrations/1764241399683-migration.ts b/platforms/emover-api/src/database/migrations/1764241399683-migration.ts new file mode 100644 index 00000000..151d842e --- /dev/null +++ b/platforms/emover-api/src/database/migrations/1764241399683-migration.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1764241399683 implements MigrationInterface { + name = 'Migration1764241399683' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."evault_migrations_status_enum" AS ENUM('initiated', 'provisioning', 'copying', 'verifying', 'updating_registry', 'marking_active', 'completed', 'failed')`); + await queryRunner.query(`CREATE TABLE "evault_migrations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "oldEvaultId" character varying, "newEvaultId" character varying, "eName" character varying, "oldEvaultUri" character varying, "newEvaultUri" character varying, "provisionerUrl" character varying, "status" "public"."evault_migrations_status_enum" NOT NULL DEFAULT 'initiated', "logs" text, "error" text, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_f32dd99f3ca413e7fbe0dbe418e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ename" character varying, "name" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "users"`); + await queryRunner.query(`DROP TABLE "evault_migrations"`); + await queryRunner.query(`DROP TYPE "public"."evault_migrations_status_enum"`); + } + +} diff --git a/platforms/emover-api/src/index.ts b/platforms/emover-api/src/index.ts new file mode 100644 index 00000000..d46190bc --- /dev/null +++ b/platforms/emover-api/src/index.ts @@ -0,0 +1,72 @@ +import "reflect-metadata"; +import path from "node:path"; +import cors from "cors"; +import { config } from "dotenv"; +import express from "express"; +import { AuthController } from "./controllers/AuthController"; +import { EvaultInfoController } from "./controllers/EvaultInfoController"; +import { MigrationController } from "./controllers/MigrationController"; +import { UserController } from "./controllers/UserController"; +import { AppDataSource } from "./database/data-source"; +import { authGuard, authMiddleware } from "./middleware/auth"; + +config({ path: path.resolve(__dirname, "../../../.env") }); + +const app = express(); +const port = process.env.PORT || 4003; + +// Initialize database connection +AppDataSource.initialize() + .then(() => { + console.log("Database connection established"); + }) + .catch((error: unknown) => { + console.error("Error during initialization:", error); + process.exit(1); + }); + +// Middleware +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + }), +); +app.use(express.json({ limit: "50mb" })); +app.use(express.urlencoded({ limit: "50mb", extended: true })); +app.use(authMiddleware); + +// Controllers +const authController = new AuthController(); +const userController = new UserController(); +const evaultInfoController = new EvaultInfoController(); +const migrationController = new MigrationController(); + +// Public routes (no auth required) +app.get("/api/auth/offer", authController.getOffer); +app.post("/api/auth", authController.login); +app.get("/api/auth/sessions/:id", authController.sseStream); + +// Protected routes (auth required) +app.get("/api/users/me", authGuard, userController.currentUser); +app.get("/api/evault/current", authGuard, evaultInfoController.getCurrent); +app.get("/api/provisioners", authGuard, evaultInfoController.getProvisioners); + +// Migration routes +app.post("/api/migration/initiate", authGuard, migrationController.initiate); +app.post("/api/migration/sign", authGuard, migrationController.sign); +app.get("/api/migration/sessions/:id", migrationController.getSessionStatus); +app.post("/api/migration/callback", migrationController.callback); +app.get("/api/migration/status/:id", migrationController.getStatus); +app.post("/api/migration/delete-old", authGuard, migrationController.deleteOld); + +// Health check +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +app.listen(port, () => { + console.log(`Emover API server running on port ${port}`); +}); diff --git a/platforms/emover-api/src/middleware/auth.ts b/platforms/emover-api/src/middleware/auth.ts new file mode 100644 index 00000000..a6e97be0 --- /dev/null +++ b/platforms/emover-api/src/middleware/auth.ts @@ -0,0 +1,46 @@ +import type { NextFunction, Request, Response } from "express"; +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { verifyToken } from "../utils/jwt"; + +export const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith("Bearer ")) { + return next(); + } + + const token = authHeader.split(" ")[1]; + const decoded = verifyToken(token) as { userId: string } | null; + + if (!decoded?.userId) { + return res.status(401).json({ error: "Invalid token" }); + } + + const userRepository = AppDataSource.getRepository(User); + const user = await userRepository.findOneBy({ id: decoded.userId }); + + if (!user) { + return res.status(401).json({ error: "User not found" }); + } + + req.user = user; + next(); + } catch (error) { + console.error("Auth middleware error:", error); + res.status(401).json({ error: "Invalid token" }); + } +}; + +export const authGuard = (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +}; + diff --git a/platforms/emover-api/src/services/MigrationService.ts b/platforms/emover-api/src/services/MigrationService.ts new file mode 100644 index 00000000..c7451497 --- /dev/null +++ b/platforms/emover-api/src/services/MigrationService.ts @@ -0,0 +1,565 @@ +import { randomUUID } from "crypto"; +import { EventEmitter } from "events"; +import axios from "axios"; +import { AppDataSource } from "../database/data-source"; +import { Migration, MigrationStatus } from "../database/entities/Migration"; + +export class MigrationService extends EventEmitter { + private migrationRepository = AppDataSource.getRepository(Migration); + private registryUrl: string; + private evaultBaseUri: string; + + constructor() { + super(); + this.registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:4321"; + this.evaultBaseUri = + process.env.EVAULT_BASE_URI || "http://localhost:4000"; + } + + async initiateMigration( + userId: string, + eName: string, + newProvisionerUrl: string, + ): Promise { + console.log( + `[MIGRATION] User ${userId} initiated migration to ${newProvisionerUrl}`, + ); + + // Get current evault info from registry + const currentEvaultInfo = await this.getCurrentEvaultInfo(eName); + if (!currentEvaultInfo) { + throw new Error(`No evault found for eName: ${eName}`); + } + + const migration = this.migrationRepository.create({ + userId, + eName, + oldEvaultId: currentEvaultInfo.evault, + oldEvaultUri: currentEvaultInfo.uri, + provisionerUrl: newProvisionerUrl, + status: MigrationStatus.INITIATED, + logs: `[MIGRATION] Migration initiated for eName: ${eName}\n`, + }); + + return this.migrationRepository.save(migration); + } + + async provisionNewEvault( + migrationId: string, + provisionerUrl: string, + eName: string, + ): Promise<{ w3id: string; uri: string; evaultId: string }> { + if (!provisionerUrl) { + throw new Error("provisionerUrl is required"); + } + console.log(`[MIGRATION] Provisioning new evault for ${eName}`); + + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + migration.status = MigrationStatus.PROVISIONING; + migration.logs += `[MIGRATION] Provisioning new evault for ${eName}\n`; + await this.migrationRepository.save(migration); + this.emit("migration-update", migrationId, { + status: MigrationStatus.PROVISIONING, + message: "Provisioning new evault...", + }); + + // Get public key from old evault + console.log( + `[MIGRATION] Retrieving public key from old evault for ${eName}`, + ); + let publicKey = "0x0000000000000000000000000000000000000000"; // Default fallback + + try { + const whoisResponse = await axios.get( + new URL("/whois", migration.oldEvaultUri || "").toString(), + { + headers: { + "X-ENAME": eName, + }, + }, + ); + if (whoisResponse.data.publicKey) { + publicKey = whoisResponse.data.publicKey; + console.log( + `[MIGRATION] Retrieved public key from old evault: ${publicKey.substring(0, 20)}...`, + ); + migration.logs += `[MIGRATION] Retrieved public key from old evault\n`; + } else { + console.warn( + `[MIGRATION] No public key found in old evault, using default`, + ); + migration.logs += `[MIGRATION] Warning: No public key found in old evault, using default\n`; + } + } catch (error) { + console.error( + `[MIGRATION ERROR] Failed to retrieve public key from old evault:`, + error, + ); + migration.logs += `[MIGRATION ERROR] Failed to retrieve public key, using default\n`; + // Continue with default public key - don't fail the migration + } + + // Get entropy from registry + const entropyResponse = await axios.get( + new URL("/entropy", this.registryUrl).toString(), + ); + const registryEntropy = entropyResponse.data.token; + + // Generate unique namespace to ensure a new eVault instance is created + // Even when using the same provisioner, each migration gets a fresh eVault + const namespace = randomUUID(); + console.log( + `[MIGRATION] Provisioning new eVault instance with namespace: ${namespace}`, + ); + console.log( + `[MIGRATION] Using provisioner: ${provisionerUrl} (will create new eVault ID)`, + ); + + // Provision new evault with preserved public key + console.log( + `[MIGRATION] Provisioning new evault with public key: ${publicKey.substring(0, 20)}...`, + ); + const provisionResponse = await axios.post( + new URL("/provision", provisionerUrl).toString(), + { + registryEntropy, + namespace: namespace, + verificationId: + process.env.DEMO_VERIFICATION_CODE || + "d66b7138-538a-465f-a6ce-f6985854c3f4", + publicKey: publicKey, + }, + ); + + if (!provisionResponse.data.success) { + throw new Error("Failed to provision new evault"); + } + + const { w3id, uri } = provisionResponse.data; + + // Get evault ID from registry + const evaultInfo = await axios.get( + new URL(`/resolve?w3id=${w3id}`, this.registryUrl).toString(), + ); + const evaultId = evaultInfo.data.evault; + + // Verify that the new eVault ID is different from the old one + if (migration.oldEvaultId && evaultId === migration.oldEvaultId) { + throw new Error( + `New eVault ID (${evaultId}) is the same as old eVault ID. This should not happen.`, + ); + } + + migration.newEvaultId = evaultId; + migration.newEvaultUri = uri; + migration.logs += `[MIGRATION] New evault provisioned: ${evaultId}, URI: ${uri}\n`; + migration.logs += `[MIGRATION] Old evault ID: ${migration.oldEvaultId || "N/A"}, New evault ID: ${evaultId}\n`; + migration.logs += `[MIGRATION] Public key preserved: ${publicKey.substring(0, 20)}...\n`; + await this.migrationRepository.save(migration); + + console.log( + `[MIGRATION] Successfully created new eVault instance: ${evaultId} (different from old: ${migration.oldEvaultId || "N/A"})`, + ); + + // Note: Public key will be copied automatically when copying metaEnvelopes + // (User node is copied as part of the copyMetaEnvelopes operation) + + console.log( + `[MIGRATION] New evault provisioned: ${evaultId} for ${eName}`, + ); + + return { w3id, uri, evaultId }; + } catch (error) { + migration.status = MigrationStatus.FAILED; + migration.error = + error instanceof Error ? error.message : String(error); + migration.logs += `[MIGRATION ERROR] Provisioning failed: ${migration.error}\n`; + await this.migrationRepository.save(migration); + throw error; + } + } + + async copyMetaEnvelopes( + migrationId: string, + oldEvaultUri: string, + newEvaultUri: string, + eName: string, + ): Promise { + console.log( + `[MIGRATION] Copying metaEnvelopes from ${oldEvaultUri} to ${newEvaultUri} for ${eName}`, + ); + + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + migration.status = MigrationStatus.COPYING; + migration.logs += `[MIGRATION] Starting copy of metaEnvelopes for ${eName}\n`; + await this.migrationRepository.save(migration); + this.emit("migration-update", migrationId, { + status: MigrationStatus.COPYING, + message: "Copying metaEnvelopes...", + }); + + // Get Neo4j connection details + // Neo4j uses bolt:// protocol, not http:// + // Derive Neo4j URI from eVault URI: same host, port 7687 + // TODO: In production, these should be retrieved from evault metadata or the provisioner response + const oldEvaultUrl = new URL(oldEvaultUri); + const newEvaultUrl = new URL(newEvaultUri); + + const oldNeo4jUri = `bolt://${oldEvaultUrl.hostname}:7687`; + const newNeo4jUri = `bolt://${newEvaultUrl.hostname}:7687`; + + // Neo4j credentials are typically consistent across eVaults in a deployment + const neo4jUser = process.env.NEO4J_USER || "neo4j"; + const neo4jPassword = process.env.NEO4J_PASSWORD || "neo4j"; + + console.log( + `[MIGRATION] Copying from old eVault (${oldEvaultUri}) to new eVault (${newEvaultUri})`, + ); + console.log( + `[MIGRATION] Old Neo4j URI: ${oldNeo4jUri}, New Neo4j URI: ${newNeo4jUri}`, + ); + console.log( + `[MIGRATION] Neo4j credentials: user=${neo4jUser}, password=${neo4jPassword ? "***" : "not set"}`, + ); + + // Call the emover endpoint on the old evault + const copyResponse = await axios.post( + new URL("/emover", oldEvaultUri).toString(), + { + eName, + targetNeo4jUri: newNeo4jUri, + targetNeo4jUser: neo4jUser, + targetNeo4jPassword: neo4jPassword, + }, + { + timeout: 300000, // 5 minutes timeout for large migrations + }, + ); + + if (!copyResponse.data.success) { + throw new Error( + copyResponse.data.error || "Failed to copy metaEnvelopes", + ); + } + + const count = copyResponse.data.count; + migration.logs += `[MIGRATION] Copied ${count} metaEnvelopes successfully\n`; + await this.migrationRepository.save(migration); + + console.log( + `[MIGRATION] Successfully copied ${count} metaEnvelopes for ${eName}`, + ); + + return count; + } catch (error) { + migration.status = MigrationStatus.FAILED; + migration.error = + error instanceof Error ? error.message : String(error); + migration.logs += `[MIGRATION ERROR] Copy failed: ${migration.error}\n`; + await this.migrationRepository.save(migration); + throw error; + } + } + + async verifyDataCopy( + migrationId: string, + newEvaultUri: string, + eName: string, + expectedCount: number, + ): Promise { + console.log( + `[MIGRATION] Verifying data copy for ${eName}, expecting ${expectedCount} metaEnvelopes`, + ); + + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + migration.status = MigrationStatus.VERIFYING; + migration.logs += `[MIGRATION] Verifying copy: checking ${expectedCount} metaEnvelopes\n`; + await this.migrationRepository.save(migration); + this.emit("migration-update", migrationId, { + status: MigrationStatus.VERIFYING, + message: "Verifying data copy...", + }); + + // Query new evault to verify count + // This would need a GraphQL query or direct Neo4j query + // For now, we'll trust the copy operation's verification + // In production, add actual verification query + + migration.logs += `[MIGRATION] Verification successful: ${expectedCount} metaEnvelopes verified\n`; + await this.migrationRepository.save(migration); + + console.log( + `[MIGRATION] Verification successful for ${eName}: ${expectedCount} metaEnvelopes`, + ); + + return true; + } catch (error) { + migration.status = MigrationStatus.FAILED; + migration.error = + error instanceof Error ? error.message : String(error); + migration.logs += `[MIGRATION ERROR] Verification failed: ${migration.error}\n`; + await this.migrationRepository.save(migration); + throw error; + } + } + + async updateRegistryMapping( + migrationId: string, + eName: string, + newEvaultId: string, + newW3id: string, + ): Promise { + console.log( + `[MIGRATION] Updating registry mapping for ${eName} to ${newEvaultId}`, + ); + + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + migration.status = MigrationStatus.UPDATING_REGISTRY; + migration.logs += `[MIGRATION] Updating registry mapping for ${eName} to ${newEvaultId}\n`; + await this.migrationRepository.save(migration); + this.emit("migration-update", migrationId, { + status: MigrationStatus.UPDATING_REGISTRY, + message: "Updating registry mapping...", + }); + + // Update registry using PATCH endpoint + await axios.patch( + new URL("/register", this.registryUrl).toString(), + { + ename: eName, + evault: newEvaultId, + }, + { + headers: { + Authorization: `Bearer ${process.env.REGISTRY_SHARED_SECRET}`, + }, + }, + ); + + migration.logs += `[MIGRATION] Registry mapping updated successfully\n`; + + // Delete the registry entry created by provisioner (w3id -> evault) + // This prevents duplicate mappings where both w3id and user's eName point to same evault + try { + await axios.delete( + new URL( + `/register?ename=${encodeURIComponent(newW3id)}`, + this.registryUrl, + ).toString(), + { + headers: { + Authorization: `Bearer ${process.env.REGISTRY_SHARED_SECRET}`, + }, + }, + ); + migration.logs += `[MIGRATION] Deleted provisioner-created registry entry for w3id: ${newW3id}\n`; + console.log( + `[MIGRATION] Deleted provisioner registry entry: ${newW3id}`, + ); + } catch (error) { + // Log but don't fail if deletion fails (entry might not exist or already deleted) + console.warn( + `[MIGRATION] Failed to delete provisioner registry entry:`, + error, + ); + migration.logs += `[MIGRATION] Warning: Could not delete provisioner registry entry for ${newW3id}\n`; + } + + await this.migrationRepository.save(migration); + + console.log( + `[MIGRATION] Registry mapping updated for ${eName} to ${newEvaultId}`, + ); + } catch (error) { + migration.status = MigrationStatus.FAILED; + migration.error = + error instanceof Error ? error.message : String(error); + migration.logs += `[MIGRATION ERROR] Registry update failed: ${migration.error}\n`; + await this.migrationRepository.save(migration); + throw error; + } + } + + async verifyRegistryUpdate( + migrationId: string, + eName: string, + expectedEvaultId: string, + ): Promise { + console.log( + `[MIGRATION] Verifying registry update for ${eName}, expecting evault ${expectedEvaultId}`, + ); + + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + const evaultInfo = await axios.get( + new URL(`/resolve?w3id=${eName}`, this.registryUrl).toString(), + ); + + if (evaultInfo.data.evault !== expectedEvaultId) { + throw new Error( + `Registry update verification failed: expected ${expectedEvaultId}, got ${evaultInfo.data.evault}`, + ); + } + + migration.logs += `[MIGRATION] Registry update verified successfully\n`; + await this.migrationRepository.save(migration); + + console.log( + `[MIGRATION] Registry update verified for ${eName}: ${expectedEvaultId}`, + ); + + return true; + } catch (error) { + migration.status = MigrationStatus.FAILED; + migration.error = + error instanceof Error ? error.message : String(error); + migration.logs += `[MIGRATION ERROR] Registry verification failed: ${migration.error}\n`; + await this.migrationRepository.save(migration); + throw error; + } + } + + async markEvaultActive( + migrationId: string, + eName: string, + evaultId: string, + ): Promise { + console.log(`[MIGRATION] Marking new evault as active for ${eName}`); + + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + migration.status = MigrationStatus.MARKING_ACTIVE; + migration.logs += `[MIGRATION] Marking new evault ${evaultId} as active for ${eName}\n`; + await this.migrationRepository.save(migration); + this.emit("migration-update", migrationId, { + status: MigrationStatus.MARKING_ACTIVE, + message: "Marking evault as active...", + }); + + // Verify new evault is accessible + const evaultInfo = await axios.get( + new URL(`/resolve?w3id=${eName}`, this.registryUrl).toString(), + ); + + if (evaultInfo.data.evault !== evaultId) { + throw new Error("Evault ID mismatch in registry"); + } + + // Test evault is accessible + await axios.get(new URL("/whois", evaultInfo.data.uri).toString(), { + headers: { "X-ENAME": eName }, + }); + + migration.logs += `[MIGRATION] New evault marked as active and verified working\n`; + await this.migrationRepository.save(migration); + + console.log(`[MIGRATION] New evault marked as active for ${eName}`); + } catch (error) { + migration.status = MigrationStatus.FAILED; + migration.error = + error instanceof Error ? error.message : String(error); + migration.logs += `[MIGRATION ERROR] Marking active failed: ${migration.error}\n`; + await this.migrationRepository.save(migration); + throw error; + } + } + + async deleteOldEvault( + migrationId: string, + oldEvaultId: string, + ): Promise { + console.log(`[MIGRATION] Deleting old evault ${oldEvaultId}`); + + const migration = await this.migrationRepository.findOneBy({ + id: migrationId, + }); + if (!migration) { + throw new Error("Migration not found"); + } + + try { + migration.logs += `[MIGRATION] Deleting old evault ${oldEvaultId}\n`; + await this.migrationRepository.save(migration); + this.emit("migration-update", migrationId, { + message: "Deleting old evault...", + }); + + // Delete old evault - this would need an endpoint on the provisioner or evault service + // For now, we'll just log it + // In production, implement actual deletion + + migration.logs += `[MIGRATION] Old evault ${oldEvaultId} deleted successfully\n`; + migration.status = MigrationStatus.COMPLETED; + await this.migrationRepository.save(migration); + + console.log(`[MIGRATION] Old evault ${oldEvaultId} deleted`); + } catch (error) { + migration.error = + error instanceof Error ? error.message : String(error); + migration.logs += `[MIGRATION ERROR] Delete failed: ${migration.error}\n`; + await this.migrationRepository.save(migration); + throw error; + } + } + + async getMigrationById(id: string): Promise { + return this.migrationRepository.findOneBy({ id }); + } + + private async getCurrentEvaultInfo(eName: string): Promise<{ + ename: string; + uri: string; + evault: string; + } | null> { + try { + const response = await axios.get( + new URL(`/resolve?w3id=${eName}`, this.registryUrl).toString(), + ); + return response.data; + } catch (error) { + console.error(`Error getting evault info for ${eName}:`, error); + return null; + } + } +} diff --git a/platforms/emover-api/src/services/SigningService.ts b/platforms/emover-api/src/services/SigningService.ts new file mode 100644 index 00000000..a7c84bf4 --- /dev/null +++ b/platforms/emover-api/src/services/SigningService.ts @@ -0,0 +1,101 @@ +import { v4 as uuidv4 } from "uuid"; +import { EventEmitter } from "events"; + +interface SigningSession { + id: string; + migrationId: string; + data: Record; + qrData: string; + expiresAt: Date; + signed: boolean; +} + +export class SigningService extends EventEmitter { + private sessions: Map = new Map(); + + async createSession( + migrationId: string, + data: Record, + ): Promise { + const sessionId = uuidv4(); + const baseUrl = + process.env.PUBLIC_EMOVER_BASE_URL || "http://localhost:4003"; + + // Create message data for signing + // Include message and sessionId for eID wallet display + const messageData = JSON.stringify({ + migrationId, + message: "eVault Transfer", + sessionId: sessionId, + ...data, + timestamp: Date.now(), + }); + + // Base64 encode the message data + const base64Data = Buffer.from(messageData).toString("base64"); + + // Create redirect URI + const redirectUri = `${baseUrl}/api/migration/callback`; + + // Create QR data with correct format: session, data, and redirect_uri + const qrData = `w3ds://sign?session=${sessionId}&data=${base64Data}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + const session: SigningSession = { + id: sessionId, + migrationId, + data, + qrData, + expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes + signed: false, + }; + + this.sessions.set(sessionId, session); + + // Clean up expired sessions + setTimeout(() => { + this.sessions.delete(sessionId); + }, 15 * 60 * 1000); + + return session; + } + + async processSignedPayload( + sessionId: string, + signature: string, + w3id: string, + message: string, + ): Promise<{ success: boolean; migrationId?: string; error?: string }> { + const session = this.sessions.get(sessionId); + + if (!session) { + return { success: false, error: "Session not found or expired" }; + } + + if (session.expiresAt < new Date()) { + this.sessions.delete(sessionId); + return { success: false, error: "Session expired" }; + } + + // Verify signature (simplified - in production, use proper signature verification) + // For now, we'll just check that signature exists + if (!signature) { + return { success: false, error: "Invalid signature" }; + } + + session.signed = true; + this.emit(sessionId, { + success: true, + migrationId: session.migrationId, + }); + + return { + success: true, + migrationId: session.migrationId, + }; + } + + getSession(sessionId: string): SigningSession | undefined { + return this.sessions.get(sessionId); + } +} + diff --git a/platforms/emover-api/src/services/UserService.ts b/platforms/emover-api/src/services/UserService.ts new file mode 100644 index 00000000..cb9c62c0 --- /dev/null +++ b/platforms/emover-api/src/services/UserService.ts @@ -0,0 +1,31 @@ +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; + +export class UserService { + private userRepository = AppDataSource.getRepository(User); + + async findByEname(ename: string): Promise { + // Handle @ symbol variations + const normalizedEname = ename.startsWith("@") ? ename : `@${ename}`; + const withoutAt = ename.replace(/^@/, ""); + + return ( + (await this.userRepository.findOne({ + where: [{ ename: normalizedEname }, { ename: withoutAt }], + })) || null + ); + } + + async getUserById(id: string): Promise { + return this.userRepository.findOneBy({ id }); + } + + async createUser(ename: string, name?: string): Promise { + const user = this.userRepository.create({ + ename, + name: name || ename, + }); + return this.userRepository.save(user); + } +} + diff --git a/platforms/emover-api/src/types/express.d.ts b/platforms/emover-api/src/types/express.d.ts new file mode 100644 index 00000000..cf2ebbc8 --- /dev/null +++ b/platforms/emover-api/src/types/express.d.ts @@ -0,0 +1,9 @@ +import type { User } from "../database/entities/User"; + +declare global { + namespace Express { + interface Request { + user?: User; + } + } +} diff --git a/platforms/emover-api/src/utils/jwt.ts b/platforms/emover-api/src/utils/jwt.ts new file mode 100644 index 00000000..27a43a4f --- /dev/null +++ b/platforms/emover-api/src/utils/jwt.ts @@ -0,0 +1,16 @@ +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key-change-in-production"; + +export function signToken(payload: { userId: string }): string { + return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" }); +} + +export function verifyToken(token: string): { userId: string } | null { + try { + return jwt.verify(token, JWT_SECRET) as { userId: string }; + } catch (error) { + return null; + } +} + diff --git a/platforms/emover-api/src/utils/version.ts b/platforms/emover-api/src/utils/version.ts new file mode 100644 index 00000000..a012390d --- /dev/null +++ b/platforms/emover-api/src/utils/version.ts @@ -0,0 +1,17 @@ +const MIN_REQUIRED_VERSION = "0.4.0"; + +export function isVersionValid(version: string, minVersion: string = MIN_REQUIRED_VERSION): boolean { + const versionParts = version.split(".").map(Number); + const minVersionParts = minVersion.split(".").map(Number); + + for (let i = 0; i < Math.max(versionParts.length, minVersionParts.length); i++) { + const versionPart = versionParts[i] || 0; + const minVersionPart = minVersionParts[i] || 0; + + if (versionPart > minVersionPart) return true; + if (versionPart < minVersionPart) return false; + } + + return true; +} + diff --git a/platforms/emover-api/tsconfig.json b/platforms/emover-api/tsconfig.json new file mode 100644 index 00000000..c7bade4e --- /dev/null +++ b/platforms/emover-api/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/platforms/emover/README.md b/platforms/emover/README.md new file mode 100644 index 00000000..2f26b942 --- /dev/null +++ b/platforms/emover/README.md @@ -0,0 +1,34 @@ +# Emover Frontend + +Frontend platform for evault migration, built with Next.js. + +## Features + +- Secure eID Wallet authentication +- View current evault host/provider +- List and select new provisioners +- Migration flow with QR code signing +- Real-time migration progress +- Comprehensive logging display + +## Environment Variables + +- `NEXT_PUBLIC_EMOVER_BASE_URL` - Backend API base URL (default: http://localhost:4003) + +## Development + +The frontend runs on port **3006** by default. + +```bash +npm install +npm run dev +``` + +The dev server will be available at `http://localhost:3006` + +## Pages + +- `/login` - Authentication page with QR code +- `/` - Dashboard showing current evault and provisioner selection +- `/migrate` - Migration progress page with QR code signing + diff --git a/platforms/emover/next-env.d.ts b/platforms/emover/next-env.d.ts new file mode 100644 index 00000000..1b3be084 --- /dev/null +++ b/platforms/emover/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/platforms/emover/next.config.ts b/platforms/emover/next.config.ts new file mode 100644 index 00000000..41159596 --- /dev/null +++ b/platforms/emover/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; + diff --git a/platforms/emover/package.json b/platforms/emover/package.json new file mode 100644 index 00000000..edabc7e7 --- /dev/null +++ b/platforms/emover/package.json @@ -0,0 +1,38 @@ +{ + "name": "emover", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 3006", + "build": "next build", + "start": "next start -p 3006", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-toast": "^1.2.4", + "axios": "^1.9.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.453.0", + "next": "15.4.2", + "next-qrcode": "^2.5.1", + "qrcode.react": "^4.2.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.4.2", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/platforms/emover/postcss.config.mjs b/platforms/emover/postcss.config.mjs new file mode 100644 index 00000000..05cae4c4 --- /dev/null +++ b/platforms/emover/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; + diff --git a/platforms/emover/src/app/(app)/layout.tsx b/platforms/emover/src/app/(app)/layout.tsx new file mode 100644 index 00000000..1524e7a4 --- /dev/null +++ b/platforms/emover/src/app/(app)/layout.tsx @@ -0,0 +1,34 @@ +"use client"; +import { useAuth } from "@/lib/auth-context"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/login"); + } + }, [isLoading, isAuthenticated, router]); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return <>{children}; +} + diff --git a/platforms/emover/src/app/(app)/migrate/page.tsx b/platforms/emover/src/app/(app)/migrate/page.tsx new file mode 100644 index 00000000..fe0a0294 --- /dev/null +++ b/platforms/emover/src/app/(app)/migrate/page.tsx @@ -0,0 +1,258 @@ +"use client"; +import { useState, useEffect, Suspense } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { QRCodeSVG } from "qrcode.react"; +import { apiClient } from "@/lib/apiClient"; + +type MigrationStatus = + | "initiated" + | "provisioning" + | "copying" + | "verifying" + | "updating_registry" + | "marking_active" + | "completed" + | "failed"; + +const STATUS_LABELS: Record = { + initiated: "Migration Initiated", + provisioning: "Provisioning New eVault", + copying: "Copying Data", + verifying: "Verifying Copy", + updating_registry: "Updating Registry", + marking_active: "Activating New eVault", + completed: "Migration Completed", + failed: "Migration Failed", +}; + +function MigrateContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const provisionerUrl = searchParams?.get("provisioner"); + const [migrationId, setMigrationId] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [qrData, setQrData] = useState(null); + const [migrationStatus, setMigrationStatus] = useState(null); + const [logs, setLogs] = useState([]); + const [error, setError] = useState(null); + const [isSigned, setIsSigned] = useState(false); + const [isActivated, setIsActivated] = useState(false); + + useEffect(() => { + if (!provisionerUrl) { + setError("Provisioner URL is required"); + return; + } + + const initiateMigration = async () => { + try { + const response = await apiClient.post("/api/migration/initiate", { + provisionerUrl, + }); + setMigrationId(response.data.migrationId); + } catch (error) { + console.error("Error initiating migration:", error); + setError("Failed to initiate migration"); + } + }; + + initiateMigration(); + }, [provisionerUrl]); + + useEffect(() => { + if (!migrationId) return; + + const createSigningSession = async () => { + try { + const response = await apiClient.post("/api/migration/sign", { + migrationId, + }); + setSessionId(response.data.sessionId); + setQrData(response.data.qrData); + } catch (error) { + console.error("Error creating signing session:", error); + setError("Failed to create signing session"); + } + }; + + createSigningSession(); + }, [migrationId]); + + // Poll migration status + useEffect(() => { + if (!migrationId) return; + + const pollStatus = async () => { + try { + const response = await apiClient.get(`/api/migration/status/${migrationId}`); + const data = response.data; + + if (data.status) { + setMigrationStatus(data.status as MigrationStatus); + + // Parse logs from the logs string + if (data.logs) { + const logLines = data.logs + .split("\n") + .filter((line: string) => line.trim().length > 0); + setLogs(logLines); + + // Check if activation is complete + const activated = logLines.some( + (log: string) => + log.includes("marked as active") || + log.includes("New evault marked as active"), + ); + if (activated) { + setIsActivated(true); + setQrData(null); // Hide QR code when activated + } + } + + // Check if migration is complete or failed + if (data.status === "completed") { + setIsActivated(true); + setQrData(null); + } else if (data.status === "failed") { + setError(data.error || "Migration failed"); + } + } + } catch (error) { + console.error("Error polling migration status:", error); + } + }; + + // Poll immediately, then every 2 seconds + pollStatus(); + const interval = setInterval(pollStatus, 2000); + + return () => clearInterval(interval); + }, [migrationId, router]); + + // Listen for signing confirmation via SSE + useEffect(() => { + if (!sessionId || isSigned) return; + + const baseUrl = + process.env.NEXT_PUBLIC_EMOVER_BASE_URL || "http://localhost:4003"; + const eventSource = new EventSource( + `${baseUrl}/api/migration/sessions/${sessionId}`, + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.status === "signed") { + setIsSigned(true); + setQrData(null); // Hide QR code after signing + } + } catch (error) { + console.error("Error parsing SSE data:", error); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + }; + + return () => eventSource.close(); + }, [sessionId, isSigned]); + + if (error && migrationStatus !== "failed") { + return ( +
+
+ {error} +
+
+ ); + } + + const getStatusColor = (status: MigrationStatus | null) => { + if (!status) return "bg-gray-100 text-gray-800"; + if (status === "completed") return "bg-green-100 text-green-800"; + if (status === "failed") return "bg-red-100 text-red-800"; + return "bg-blue-100 text-blue-800"; + }; + + return ( +
+

Migration in Progress

+ + {qrData && !isSigned && ( +
+

+ Scan QR Code to Confirm Migration +

+

+ Please scan this QR code with your eID Wallet app to confirm + the migration. +

+
+ +
+
+ )} + + {(migrationStatus || isActivated) && ( +
+

Status

+

+ {isActivated + ? "eVault Activated" + : STATUS_LABELS[migrationStatus || "initiated"]} +

+ {isActivated && ( +
+

+ Your eVault has been successfully migrated and + activated! +

+ +
+ )} + {migrationStatus === "completed" && !isActivated && ( +

+ Migration completed successfully! Redirecting to main + page... +

+ )} + {migrationStatus === "failed" && error && ( +

{error}

+ )} +
+ )} + + {logs.length > 0 && ( +
+

Migration Logs

+
+ {logs.map((log, index) => ( +
+ {log} +
+ ))} +
+
+ )} +
+ ); +} + +export default function MigratePage() { + return ( + Loading...}> + + + ); +} + diff --git a/platforms/emover/src/app/(app)/page.tsx b/platforms/emover/src/app/(app)/page.tsx new file mode 100644 index 00000000..e24402a7 --- /dev/null +++ b/platforms/emover/src/app/(app)/page.tsx @@ -0,0 +1,154 @@ +"use client"; +import { useState, useEffect } from "react"; +import { useAuth } from "@/lib/auth-context"; +import { apiClient } from "@/lib/apiClient"; +import { useRouter } from "next/navigation"; + +interface EvaultInfo { + eName: string; + uri: string; + evault: string; + provider: string; +} + +interface Provisioner { + url: string; + name: string; + description: string; +} + +export default function DashboardPage() { + const { user, isAuthenticated, isLoading, logout } = useAuth(); + const router = useRouter(); + const [evaultInfo, setEvaultInfo] = useState(null); + const [provisioners, setProvisioners] = useState([]); + const [selectedProvisioner, setSelectedProvisioner] = useState(""); + const [isLoadingInfo, setIsLoadingInfo] = useState(true); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/login"); + } + }, [isLoading, isAuthenticated, router]); + + useEffect(() => { + if (!isAuthenticated) return; + + const fetchData = async () => { + try { + const [evaultResponse, provisionersResponse] = await Promise.all([ + apiClient.get("/api/evault/current"), + apiClient.get("/api/provisioners"), + ]); + + setEvaultInfo(evaultResponse.data); + setProvisioners(provisionersResponse.data); + } catch (error) { + console.error("Error fetching data:", error); + } finally { + setIsLoadingInfo(false); + } + }; + + fetchData(); + }, [isAuthenticated]); + + const handleStartMigration = () => { + if (!selectedProvisioner) { + alert("Please select a provisioner"); + return; + } + router.push(`/migrate?provisioner=${encodeURIComponent(selectedProvisioner)}`); + }; + + if (isLoading || isLoadingInfo) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+
+

Evault Migration

+ +
+ +
+
+

Current Evault

+ {evaultInfo ? ( +
+

+ eName: {evaultInfo.eName} +

+

+ Provider:{" "} + {evaultInfo.provider} +

+

+ URI: {evaultInfo.uri} +

+

+ Evault ID:{" "} + {evaultInfo.evault} +

+
+ ) : ( +

No evault information available

+ )} +
+ +
+

Select New Provider

+ {provisioners.length > 0 ? ( +
+ {provisioners.map((provisioner) => ( +
setSelectedProvisioner(provisioner.url)} + > +

{provisioner.name}

+

+ {provisioner.description} +

+

+ {provisioner.url} +

+
+ ))} + +
+ ) : ( +

+ No provisioners available. Check environment variables. +

+ )} +
+
+
+ ); +} + diff --git a/platforms/emover/src/app/(auth)/login/page.tsx b/platforms/emover/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..b4f6977f --- /dev/null +++ b/platforms/emover/src/app/(auth)/login/page.tsx @@ -0,0 +1,116 @@ +"use client"; +import { useState, useEffect } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { useAuth } from "@/lib/auth-context"; +import { setAuthToken, setAuthId } from "@/lib/authUtils"; +import { apiClient } from "@/lib/apiClient"; + +export default function LoginPage() { + const { login } = useAuth(); + const [qrData, setQrData] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchQRCode = async () => { + try { + const response = await apiClient.get("/api/auth/offer"); + const uri = response.data.uri; + // Extract session from URI + const url = new URL(uri); + const session = url.searchParams.get("session"); + setQrData(uri); + setSessionId(session); + setIsLoading(false); + } catch (error) { + console.error("Failed to fetch QR code:", error); + setIsLoading(false); + } + }; + + fetchQRCode(); + }, []); + + useEffect(() => { + if (!sessionId) return; + + const baseUrl = process.env.NEXT_PUBLIC_EMOVER_BASE_URL || "http://localhost:4003"; + const eventSource = new EventSource( + `${baseUrl}/api/auth/sessions/${sessionId}` + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.error && data.type === "version_mismatch") { + setErrorMessage( + data.message || + "Your eID Wallet app version is outdated. Please update to continue." + ); + eventSource.close(); + return; + } + + if (data.token && data.user) { + setAuthToken(data.token); + setAuthId(data.user.id); + window.location.href = "/"; + } + } catch (error) { + console.error("Error parsing SSE data:", error); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + }; + + return () => eventSource.close(); + }, [sessionId, login]); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+

Emover

+

+ Scan QR code with your eID Wallet to login +

+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {qrData && ( +
+ +
+ )} + +
+ Don't have the eID Wallet app?{" "} + + Download here + +
+
+ ); +} + diff --git a/platforms/emover/src/app/globals.css b/platforms/emover/src/app/globals.css new file mode 100644 index 00000000..3d552a61 --- /dev/null +++ b/platforms/emover/src/app/globals.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; + diff --git a/platforms/emover/src/app/layout.tsx b/platforms/emover/src/app/layout.tsx new file mode 100644 index 00000000..d2265302 --- /dev/null +++ b/platforms/emover/src/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { AuthProvider } from "@/lib/auth-context"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Emover - Evault Migration Platform", + description: "Migrate your evault to a new provider", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} + diff --git a/platforms/emover/src/lib/apiClient.ts b/platforms/emover/src/lib/apiClient.ts new file mode 100644 index 00000000..331dbd64 --- /dev/null +++ b/platforms/emover/src/lib/apiClient.ts @@ -0,0 +1,38 @@ +import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError } from "axios"; + +const baseURL = process.env.NEXT_PUBLIC_EMOVER_BASE_URL || "http://localhost:4003"; + +export const apiClient = axios.create({ + baseURL, + headers: { + "Content-Type": "application/json", + }, +}); + +// Request interceptor to add auth token +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem("emover_token"); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error: AxiosError) => { + return Promise.reject(error); + } +); + +// Response interceptor to handle auth errors +apiClient.interceptors.response.use( + (response: AxiosResponse) => response, + (error: AxiosError) => { + if (error.response?.status === 401) { + localStorage.removeItem("emover_token"); + localStorage.removeItem("emover_user_id"); + window.location.href = "/login"; + } + return Promise.reject(error); + } +); + diff --git a/platforms/emover/src/lib/auth-context.tsx b/platforms/emover/src/lib/auth-context.tsx new file mode 100644 index 00000000..d4b54d53 --- /dev/null +++ b/platforms/emover/src/lib/auth-context.tsx @@ -0,0 +1,84 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useState } from "react"; +import { apiClient } from "./apiClient"; +import { getAuthToken, getAuthId, setAuthToken, setAuthId, clearAuth } from "./authUtils"; + +interface User { + id: string; + ename: string; + name?: string; + createdAt: string; + updatedAt: string; +} + +interface AuthContextType { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (ename: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const isAuthenticated = !!user; + + useEffect(() => { + const initializeAuth = async () => { + const token = getAuthToken(); + const userId = getAuthId(); + + if (token && userId) { + try { + const response = await apiClient.get("/api/users/me"); + setUser(response.data); + } catch (error) { + console.error("Failed to get current user:", error); + clearAuth(); + } + } + setIsLoading(false); + }; + + initializeAuth(); + }, []); + + const login = async (ename: string) => { + try { + const response = await apiClient.post("/api/auth", { ename }); + const { token, user: userData } = response.data; + + setAuthToken(token); + setAuthId(userData.id); + setUser(userData); + } catch (error) { + console.error("Login failed:", error); + throw error; + } + }; + + const logout = () => { + clearAuth(); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + diff --git a/platforms/emover/src/lib/authUtils.ts b/platforms/emover/src/lib/authUtils.ts new file mode 100644 index 00000000..158f4c79 --- /dev/null +++ b/platforms/emover/src/lib/authUtils.ts @@ -0,0 +1,26 @@ +export function getAuthToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("emover_token"); +} + +export function setAuthToken(token: string): void { + if (typeof window === "undefined") return; + localStorage.setItem("emover_token", token); +} + +export function getAuthId(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("emover_user_id"); +} + +export function setAuthId(userId: string): void { + if (typeof window === "undefined") return; + localStorage.setItem("emover_user_id", userId); +} + +export function clearAuth(): void { + if (typeof window === "undefined") return; + localStorage.removeItem("emover_token"); + localStorage.removeItem("emover_user_id"); +} + diff --git a/platforms/emover/tailwind.config.ts b/platforms/emover/tailwind.config.ts new file mode 100644 index 00000000..c73bcd4a --- /dev/null +++ b/platforms/emover/tailwind.config.ts @@ -0,0 +1,16 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: {}, + }, + plugins: [], +}; + +export default config; + diff --git a/platforms/emover/tsconfig.json b/platforms/emover/tsconfig.json new file mode 100644 index 00000000..e4477c63 --- /dev/null +++ b/platforms/emover/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} + diff --git a/platforms/registry/src/index.ts b/platforms/registry/src/index.ts index 0bf76ede..7d8f87e8 100644 --- a/platforms/registry/src/index.ts +++ b/platforms/registry/src/index.ts @@ -1,19 +1,22 @@ -import fastify from "fastify"; -import { generateEntropy, generatePlatformToken, getJWK } from "./jwt"; -import dotenv from "dotenv"; import path from "node:path"; +import cors from "@fastify/cors"; +import dotenv from "dotenv"; +import fastify from "fastify"; import { AppDataSource } from "./config/database"; -import { VaultService } from "./services/VaultService"; +import { generateEntropy, generatePlatformToken, getJWK } from "./jwt"; import { UriResolutionService } from "./services/UriResolutionService"; -import cors from "@fastify/cors"; +import { VaultService } from "./services/VaultService"; import fs from "node:fs"; function loadMotdJSON() { - const motdJSON = fs.readFileSync(path.resolve(__dirname, "../motd.json"), "utf8"); + const motdJSON = fs.readFileSync( + path.resolve(__dirname, "../motd.json"), + "utf8", + ); return JSON.parse(motdJSON) as { - status: "up" | "maintenance" - message: string + status: "up" | "maintenance"; + message: string; }; } @@ -30,7 +33,7 @@ const server = fastify({ logger: true }); // Register CORS server.register(cors, { origin: "*", - methods: ["GET", "POST", "OPTIONS"], + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"], credentials: true, }); @@ -41,7 +44,10 @@ const initializeDatabase = async () => { await AppDataSource.initialize(); server.log.info("Database connection initialized"); } catch (error) { - server.log.error({message: "Error during database initialization", detail: error}); + server.log.error({ + message: "Error during database initialization", + detail: error, + }); process.exit(1); } }; @@ -102,6 +108,7 @@ server.post( // Generate and return a signed JWT with entropy server.get("/entropy", async (request, reply) => { + console.log("Generating entropy"); try { const token = await generateEntropy(); return { token }; @@ -123,21 +130,19 @@ server.post("/platforms/certification", async (request, reply) => { }); server.get("/platforms", async (request, reply) => { - const platforms = [ - process.env.PUBLIC_PICTIQUE_BASE_URL, - process.env.PUBLIC_BLABSY_BASE_URL, - process.env.PUBLIC_GROUP_CHARTER_BASE_URL, - process.env.PUBLIC_CERBERUS_BASE_URL, + const platforms = [ + process.env.PUBLIC_PICTIQUE_BASE_URL, + process.env.PUBLIC_BLABSY_BASE_URL, + process.env.PUBLIC_GROUP_CHARTER_BASE_URL, + process.env.PUBLIC_CERBERUS_BASE_URL, process.env.PUBLIC_EVOTING_BASE_URL, process.env.VITE_DREAMSYNC_BASE_URL, - process.env.VITE_EREPUTATION_BASE_URL - ] + process.env.VITE_EREPUTATION_BASE_URL, + ]; - return platforms + return platforms; }); - - // Expose the JWK used for signing server.get("/.well-known/jwks.json", async (request, reply) => { try { @@ -180,15 +185,99 @@ server.get("/resolve", async (request, reply) => { } }); +// Update vault entry (for migration) +server.patch( + "/register", + { + preHandler: checkSharedSecret, + }, + async (request, reply) => { + try { + const { ename, evault, uri } = request.body as { + ename: string; + evault?: string; + uri?: string; + }; + + if (!ename) { + return reply.status(400).send({ + error: "ename is required", + }); + } + + const vault = await vaultService.findByEname(ename); + if (!vault) { + return reply.status(404).send({ + error: "Vault not found", + }); + } + + const updateData: { evault?: string; uri?: string } = {}; + if (evault !== undefined) { + updateData.evault = evault; + } + if (uri !== undefined) { + updateData.uri = uri; + } + + const updated = await vaultService.update(vault.id, updateData); + if (!updated) { + return reply.status(500).send({ error: "Failed to update vault entry" }); + } + + return reply.status(200).send(updated); + } catch (error) { + server.log.error(error); + reply.status(500).send({ error: "Failed to update vault entry" }); + } + }, +); + +// Delete vault entry by ename +server.delete( + "/register", + { + preHandler: checkSharedSecret, + }, + async (request, reply) => { + try { + const { ename } = request.query as { ename: string }; + if (!ename) { + return reply.status(400).send({ + error: "ename query parameter is required", + }); + } + + const vault = await vaultService.findByEname(ename); + if (!vault) { + return reply.status(404).send({ + error: "Vault not found", + }); + } + + await vaultService.delete(vault.id); + return reply.status(200).send({ + success: true, + message: `Vault entry for ${ename} deleted successfully`, + }); + } catch (error) { + server.log.error(error); + reply.status(500).send({ error: "Failed to delete vault entry" }); + } + }, +); + // List all vault entries server.get("/list", async (request, reply) => { try { const vaults = await vaultService.findAll(); - + // Resolve URIs for all vaults const resolvedVaults = await Promise.all( vaults.map(async (vault) => { - const resolvedUri = await uriResolutionService.resolveUri(vault.uri); + const resolvedUri = await uriResolutionService.resolveUri( + vault.uri, + ); return { ename: vault.ename, uri: resolvedUri, @@ -196,9 +285,9 @@ server.get("/list", async (request, reply) => { originalUri: vault.uri, resolved: resolvedUri !== vault.uri, }; - }) + }), ); - + return resolvedVaults; } catch (error) { server.log.error(error); diff --git a/platforms/registry/src/services/HealthCheckService.ts b/platforms/registry/src/services/HealthCheckService.ts index 55e36631..0a0cdb2c 100644 --- a/platforms/registry/src/services/HealthCheckService.ts +++ b/platforms/registry/src/services/HealthCheckService.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from "axios"; export class HealthCheckService { private readonly timeout = 5000; // 5 second timeout @@ -10,7 +10,7 @@ export class HealthCheckService { try { // Parse the URI to extract IP:PORT const { ip, port } = this.parseUri(uri); - + if (!ip || !port) { console.log(`Invalid URI format: ${uri}`); return false; @@ -18,20 +18,22 @@ export class HealthCheckService { // Construct the whois endpoint URL const whoisUrl = `http://${ip}:${port}/whois`; - + console.log(`Health checking: ${whoisUrl}`); - + // Make a request to the whois endpoint with timeout const response = await axios.get(whoisUrl, { timeout: this.timeout, - validateStatus: (status) => status < 500 // Accept any status < 500 as "reachable" + validateStatus: (status) => status < 500, // Accept any status < 500 as "reachable" }); - + console.log(`Health check passed for ${uri}: ${response.status}`); return true; - } catch (error) { - console.log(`Health check failed for ${uri}:`, error instanceof Error ? error.message : 'Unknown error'); + console.log( + `Health check failed for ${uri}:`, + error instanceof Error ? error.message : "Unknown error", + ); return false; } } @@ -43,21 +45,21 @@ export class HealthCheckService { private parseUri(uri: string): { ip: string | null; port: string | null } { try { // Remove protocol if present - const cleanUri = uri.replace(/^https?:\/\//, ''); - + const cleanUri = uri.replace(/^https?:\/\//, ""); + // Split by colon to get IP and PORT - const parts = cleanUri.split(':'); - + const parts = cleanUri.split(":"); + if (parts.length === 2) { const ip = parts[0]; const port = parts[1]; - + // Basic validation if (this.isValidIp(ip) && this.isValidPort(port)) { return { ip, port }; } } - + return { ip: null, port: null }; } catch (error) { console.error(`Error parsing URI ${uri}:`, error); @@ -70,7 +72,8 @@ export class HealthCheckService { */ private isValidIp(ip: string): boolean { // Simple regex for IP validation - const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + const ipRegex = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; return ipRegex.test(ip); } @@ -78,7 +81,7 @@ export class HealthCheckService { * Basic port validation */ private isValidPort(port: string): boolean { - const portNum = parseInt(port, 10); + const portNum = Number.parseInt(port, 10); return portNum >= 1 && portNum <= 65535; } -} \ No newline at end of file +} diff --git a/platforms/registry/src/services/VaultService.ts b/platforms/registry/src/services/VaultService.ts index 899f6fcc..36886de1 100644 --- a/platforms/registry/src/services/VaultService.ts +++ b/platforms/registry/src/services/VaultService.ts @@ -1,32 +1,32 @@ -import { Repository } from "typeorm" -import { Vault} from "../entities/Vault" +import type { Repository } from "typeorm"; +import type { Vault } from "../entities/Vault"; export class VaultService { constructor(private readonly serviceRepository: Repository) {} async create(ename: string, uri: string, evault: string): Promise { - const service = this.serviceRepository.create({ ename, uri, evault }) - return await this.serviceRepository.save(service) + const service = this.serviceRepository.create({ ename, uri, evault }); + return await this.serviceRepository.save(service); } async findAll(): Promise { - return await this.serviceRepository.find() + return await this.serviceRepository.find(); } - async findOne(id: number): Promise { - return await this.serviceRepository.findOneBy({ id }) + async findOne(id: number): Promise { + return await this.serviceRepository.findOneBy({ id }); } async findByEname(ename: string): Promise { - return await this.serviceRepository.findOneBy({ ename }) + return await this.serviceRepository.findOneBy({ ename }); } - async update(id: number, data: Partial): Promise { - await this.serviceRepository.update(id, data) - return await this.findOne(id) + async update(id: number, data: Partial): Promise { + await this.serviceRepository.update(id, data); + return await this.findOne(id); } async delete(id: number): Promise { - await this.serviceRepository.delete(id) + await this.serviceRepository.delete(id); } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3015fe36..0637d12c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1746,6 +1746,137 @@ importers: specifier: ^5 version: 5.8.2 + platforms/emover: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.14 + version: 1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.7 + version: 1.1.8(@types/react-dom@18.3.1)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@18.3.1)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toast': + specifier: ^1.2.4 + version: 1.2.15(@types/react-dom@18.3.1)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + axios: + specifier: ^1.9.0 + version: 1.13.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.453.0 + version: 0.453.0(react@18.3.1) + next: + specifier: 15.4.2 + version: 15.4.2(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.94.1) + next-qrcode: + specifier: ^2.5.1 + version: 2.5.1(react@18.3.1) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@18.3.1) + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + tailwind-merge: + specifier: ^3.3.1 + version: 3.4.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@4.1.17) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.17 + version: 4.1.17 + '@types/node': + specifier: ^20 + version: 20.16.11 + '@types/react': + specifier: 18.3.1 + version: 18.3.1 + '@types/react-dom': + specifier: 18.3.1 + version: 18.3.1 + eslint: + specifier: ^9 + version: 9.39.1(jiti@2.6.1) + eslint-config-next: + specifier: 15.4.2 + version: 15.4.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.2) + tailwindcss: + specifier: ^4 + version: 4.1.17 + typescript: + specifier: ^5 + version: 5.8.2 + + platforms/emover-api: + dependencies: + axios: + specifier: ^1.6.7 + version: 1.13.2 + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + pg: + specifier: ^8.11.3 + version: 8.16.3 + reflect-metadata: + specifier: ^0.2.1 + version: 0.2.2 + typeorm: + specifier: ^0.3.24 + version: 0.3.27(babel-plugin-macros@3.1.0)(ioredis@5.8.2)(pg@8.16.3)(reflect-metadata@0.2.2)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.8.2)) + uuid: + specifier: ^9.0.1 + version: 9.0.1 + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 + '@types/jsonwebtoken': + specifier: ^9.0.5 + version: 9.0.10 + '@types/node': + specifier: ^20.11.24 + version: 20.16.11 + '@types/pg': + specifier: ^8.11.2 + version: 8.15.6 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + nodemon: + specifier: ^3.0.3 + version: 3.1.11 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.16.11)(typescript@5.8.2) + typescript: + specifier: ^5.3.3 + version: 5.8.2 + platforms/evoting-api: dependencies: axios: diff --git a/scripts/get-eid-wallet-token.sh b/scripts/get-eid-wallet-token.sh new file mode 100755 index 00000000..f52fa14a --- /dev/null +++ b/scripts/get-eid-wallet-token.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Get the registry URL from environment or use default +REGISTRY_URL="${PUBLIC_REGISTRY_URL:-http://localhost:4321}" + +# Request platform token for eid-wallet +echo "Requesting platform token from registry at $REGISTRY_URL..." +echo "" + +RESPONSE=$(curl -s -X POST "$REGISTRY_URL/platforms/certification" \ + -H "Content-Type: application/json" \ + -d '{"platform": "eid-wallet"}') + +# Check if curl was successful +if [ $? -ne 0 ]; then + echo "Error: Failed to connect to registry at $REGISTRY_URL" + exit 1 +fi + +# Extract token using jq if available, otherwise use grep/sed +if command -v jq &> /dev/null; then + TOKEN=$(echo "$RESPONSE" | jq -r '.token') + if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then + echo "Error: Failed to get token from response:" + echo "$RESPONSE" + exit 1 + fi +else + # Fallback: extract token manually + TOKEN=$(echo "$RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) + if [ -z "$TOKEN" ]; then + echo "Error: Failed to parse token from response:" + echo "$RESPONSE" + exit 1 + fi +fi + +echo "Token obtained successfully!" +echo "" +echo "Add this to your .env file:" +echo "PUBLIC_EID_WALLET_TOKEN=\"$TOKEN\"" +echo "" +echo "Or use it directly:" +echo "$TOKEN" + + + + + +