diff --git a/infrastructure/control-panel/config/admin-enames.json b/infrastructure/control-panel/config/admin-enames.json index 1827d693c..2c1513ee4 100644 --- a/infrastructure/control-panel/config/admin-enames.json +++ b/infrastructure/control-panel/config/admin-enames.json @@ -3,7 +3,6 @@ "@7218b67d-da21-54d6-9a85-7c4db1d09768", "@82f7a77a-f03a-52aa-88fc-1b1e488ad498", "@35a31f0d-dd76-5780-b383-29f219fcae99", - "@82f7a77a-f03a-52aa-88fc-1b1e488ad498", - "@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c" + "@b995a88a-90d1-56fc-ba42-1e1eb664861c" ] } diff --git a/infrastructure/control-panel/package.json b/infrastructure/control-panel/package.json index e38357874..f6a9ef6a5 100644 --- a/infrastructure/control-panel/package.json +++ b/infrastructure/control-panel/package.json @@ -43,6 +43,8 @@ "vite": "^7.0.4" }, "dependencies": { + "graphql-request": "^7.3.1", + "@metastate-foundation/types": "workspace:*", "@hugeicons/core-free-icons": "^1.0.13", "@hugeicons/svelte": "^1.0.2", "@inlang/paraglide-js": "^2.0.0", diff --git a/infrastructure/control-panel/src/lib/server/evault.ts b/infrastructure/control-panel/src/lib/server/evault.ts new file mode 100644 index 000000000..16ae58ee6 --- /dev/null +++ b/infrastructure/control-panel/src/lib/server/evault.ts @@ -0,0 +1,258 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { PUBLIC_CONTROL_PANEL_URL, PUBLIC_REGISTRY_URL } from '$env/static/public'; +import type { BindingDocument, SocialConnection } from '@metastate-foundation/types'; + +const BINDING_DOCUMENTS_QUERY = gql` + query GetBindingDocuments($first: Int!, $after: String) { + bindingDocuments(first: $first, after: $after) { + edges { + node { + id + parsed + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +const USER_PROFILE_QUERY = gql` + query GetUserProfile($ontologyId: ID!, $first: Int!) { + metaEnvelopes(filter: { ontologyId: $ontologyId }, first: $first) { + edges { + node { + parsed + } + } + } + } +`; + +const USER_PROFILE_ONTOLOGY = '550e8400-e29b-41d4-a716-446655440000'; + +interface RegistryResolveResponse { + evaultUrl?: string; + uri?: string; +} + +interface PlatformCertificationResponse { + token: string; +} + +interface BindingDocumentsResponse { + bindingDocuments: { + edges: Array<{ + node: { + id: string; + parsed: Record | null; + }; + }>; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + }; +} + +class EvaultService { + private platformToken: string | null = null; + private profileNameCache = new Map(); + + private getRegistryUrl(): string { + const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.w3ds.metastate.foundation'; + return registryUrl; + } + + private getGraphqlUrl(evaultBaseUrl: string): string { + return new URL('/graphql', evaultBaseUrl).toString(); + } + + normalizeEName(value: string): string { + return value.startsWith('@') ? value : `@${value}`; + } + + private async getPlatformToken(): Promise { + if (this.platformToken) return this.platformToken; + const platform = PUBLIC_CONTROL_PANEL_URL || 'control-panel'; + const endpoint = new URL('/platforms/certification', this.getRegistryUrl()).toString(); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platform }), + signal: AbortSignal.timeout(10_000) + }); + + if (!response.ok) { + throw new Error(`Failed to get platform token: HTTP ${response.status}`); + } + + const data = (await response.json()) as PlatformCertificationResponse; + if (!data.token) { + throw new Error('Failed to get platform token: missing token in response'); + } + + this.platformToken = data.token; + return this.platformToken; + } + + async resolveEVaultUrl(eName: string): Promise { + const normalized = this.normalizeEName(eName); + const endpoint = new URL( + `/resolve?w3id=${encodeURIComponent(normalized)}`, + this.getRegistryUrl() + ).toString(); + + const response = await fetch(endpoint, { + signal: AbortSignal.timeout(10_000) + }); + + if (!response.ok) { + throw new Error(`Registry resolve failed: HTTP ${response.status}`); + } + + const data = (await response.json()) as RegistryResolveResponse; + const resolved = data.evaultUrl ?? data.uri; + + if (!resolved) { + throw new Error('Registry did not return an eVault URL'); + } + + return resolved; + } + + private async resolveDisplayNameForEName(eName: string): Promise { + const normalized = this.normalizeEName(eName); + const cached = this.profileNameCache.get(normalized); + if (cached) return cached; + + const [evaultBaseUrl, token] = await Promise.all([ + this.resolveEVaultUrl(normalized), + this.getPlatformToken() + ]); + + const client = new GraphQLClient(this.getGraphqlUrl(evaultBaseUrl), { + headers: { + Authorization: `Bearer ${token}`, + 'X-ENAME': normalized + } + }); + + const response = await client.request<{ + metaEnvelopes?: { + edges?: Array<{ node?: { parsed?: Record | null } }>; + }; + }>(USER_PROFILE_QUERY, { + ontologyId: USER_PROFILE_ONTOLOGY, + first: 1 + }); + + const profile = response.metaEnvelopes?.edges?.[0]?.node?.parsed; + const displayName = + (typeof profile?.displayName === 'string' && profile.displayName) || + (typeof profile?.name === 'string' && profile.name) || + normalized; + + this.profileNameCache.set(normalized, displayName); + return displayName; + } + + async fetchBindingDocuments(eName: string): Promise<{ + eName: string; + documents: BindingDocument[]; + socialConnections: SocialConnection[]; + }> { + const normalized = this.normalizeEName(eName); + const [evaultBaseUrl, token] = await Promise.all([ + this.resolveEVaultUrl(normalized), + this.getPlatformToken() + ]); + + const client = new GraphQLClient(this.getGraphqlUrl(evaultBaseUrl), { + headers: { + Authorization: `Bearer ${token}`, + 'X-ENAME': normalized + } + }); + + const allEdges: Array<{ node: { id: string; parsed: Record | null } }> = []; + let afterCursor: string | null = null; + + do { + const res: BindingDocumentsResponse = await client.request( + BINDING_DOCUMENTS_QUERY, + { first: 100, after: afterCursor ?? undefined } + ); + allEdges.push(...res.bindingDocuments.edges); + afterCursor = res.bindingDocuments.pageInfo.hasNextPage + ? res.bindingDocuments.pageInfo.endCursor + : null; + } while (afterCursor !== null); + + const documents: BindingDocument[] = allEdges + .map((edge) => { + const parsed = edge.node.parsed; + if (!parsed || typeof parsed !== 'object') return null; + const { subject, type, data, signatures } = parsed; + if ( + typeof subject !== 'string' || + typeof type !== 'string' || + typeof data !== 'object' || + data === null || + !Array.isArray(signatures) + ) { + return null; + } + return { + id: edge.node.id, + subject, + type: type as BindingDocument['type'], + data: data as Record, + signatures: signatures as BindingDocument['signatures'] + }; + }) + .filter((doc): doc is BindingDocument => doc !== null); + + const socialCandidates = documents.filter( + (doc) => doc.type === 'social_connection' && doc.signatures.length === 2 + ); + + const socialConnections = ( + await Promise.all( + socialCandidates.map(async (doc) => { + const otherPartyEName = doc.signatures.find( + (signature) => signature.signer !== normalized + )?.signer; + + if (!otherPartyEName) return null; + + let name: string; + try { + name = await this.resolveDisplayNameForEName(otherPartyEName); + } catch { + name = otherPartyEName; + } + + const relationDescription = + typeof doc.data?.relation_description === 'string' + ? doc.data.relation_description + : null; + + return { + id: doc.id, + name, + witnessEName: otherPartyEName, + relationDescription: relationDescription || undefined, + signatures: doc.signatures + }; + }) + ) + ).filter((entry): entry is NonNullable => entry !== null); + + return { eName: normalized, documents, socialConnections }; + } +} + +export const evaultService = new EvaultService(); diff --git a/infrastructure/control-panel/src/lib/services/evaultService.ts b/infrastructure/control-panel/src/lib/services/evaultService.ts index 4c1f00118..cbc843448 100644 --- a/infrastructure/control-panel/src/lib/services/evaultService.ts +++ b/infrastructure/control-panel/src/lib/services/evaultService.ts @@ -1,4 +1,5 @@ import type { EVault } from '../../routes/api/evaults/+server'; +import type { BindingDocument, SocialConnection } from '@metastate-foundation/types'; import { cacheService } from './cacheService'; export class EVaultService { @@ -115,6 +116,32 @@ export class EVaultService { } } + /** + * Get binding documents for a specific eVault by evaultId + */ + static async getBindingDocuments( + evaultId: string + ): Promise<{ documents: BindingDocument[]; socialConnections: SocialConnection[]; eName: string }> { + try { + const response = await fetch( + `/api/evaults/${encodeURIComponent(evaultId)}/binding-documents` + ); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `HTTP error! status: ${response.status}`); + } + const data = await response.json(); + return { + documents: data.documents || [], + socialConnections: data.socialConnections || [], + eName: data.eName || '' + }; + } catch (error) { + console.error('Failed to fetch binding documents:', error); + throw error; + } + } + /** * Get logs for a specific eVault by namespace and podName */ diff --git a/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/binding-documents/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/binding-documents/+server.ts new file mode 100644 index 000000000..d23bbfe05 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/binding-documents/+server.ts @@ -0,0 +1,29 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { registryService } from '$lib/services/registry'; +import { evaultService } from '$lib/server/evault'; + +export const GET: RequestHandler = async ({ params }) => { + const { evaultId } = params; + + try { + const evaults = await registryService.getEVaults(); + if (evaults.length === 0) { + return json( + { error: 'Registry unavailable: failed to fetch eVaults' }, + { status: 502 } + ); + } + const vault = evaults.find((v) => v.evault === evaultId || v.ename === evaultId); + if (!vault) { + return json({ error: `eVault '${evaultId}' not found in registry.` }, { status: 404 }); + } + + const result = await evaultService.fetchBindingDocuments(vault.ename); + return json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to fetch binding documents'; + return json({ error: message }, { status: 500 }); + } +}; diff --git a/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte b/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte index 372b688d7..6425eb9e3 100644 --- a/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte +++ b/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte @@ -2,6 +2,7 @@ import { page } from '$app/stores'; import { EVaultService } from '$lib/services/evaultService'; import { onMount } from 'svelte'; + import type { BindingDocument, SocialConnection } from '@metastate-foundation/types'; import type { EVault } from '../../api/evaults/+server'; let evault = $state(null); @@ -11,8 +12,18 @@ let error = $state(null); let selectedTab = $state('logs'); + let bindingDocumentsLoading = $state(false); + let bindingDocumentsError = $state(null); + let documents = $state([]); + let socialConnections = $state([]); + const evaultId = $page.params.evaultId; + function getDataValue(data: Record, key: string): string { + const value = data[key]; + return typeof value === 'string' ? value : 'N/A'; + } + const fetchEVaultDetails = async () => { if (!evaultId) { error = 'Invalid evault ID'; @@ -49,13 +60,44 @@ } }; + const fetchBindingDocuments = async () => { + if (!evaultId) return; + + bindingDocumentsLoading = true; + bindingDocumentsError = null; + try { + const result = await EVaultService.getBindingDocuments(evaultId); + documents = result.documents; + socialConnections = result.socialConnections; + } catch (err) { + bindingDocumentsError = + err instanceof Error ? err.message : 'Failed to fetch binding documents'; + } finally { + bindingDocumentsLoading = false; + } + }; + const refreshData = () => { fetchEVaultDetails(); + if (selectedTab === 'binding-documents') { + fetchBindingDocuments(); + } + }; + + const selectTab = (tab: string) => { + selectedTab = tab; + if (tab === 'binding-documents') { + fetchBindingDocuments(); + } }; onMount(() => { fetchEVaultDetails(); }); + + let idDocuments = $derived(documents.filter((doc) => doc.type === 'id_document')); + let selfDocuments = $derived(documents.filter((doc) => doc.type === 'self')); + let photoDocuments = $derived(documents.filter((doc) => doc.type === 'photograph'));
@@ -95,7 +137,7 @@ class="border-b-2 px-1 py-2 text-sm font-medium {selectedTab === 'logs' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'}" - onclick={() => (selectedTab = 'logs')} + onclick={() => selectTab('logs')} > Logs @@ -103,10 +145,18 @@ class="border-b-2 px-1 py-2 text-sm font-medium {selectedTab === 'details' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'}" - onclick={() => (selectedTab = 'details')} + onclick={() => selectTab('details')} > Details +
@@ -183,6 +233,125 @@ {/if} + {:else if selectedTab === 'binding-documents'} +
+

Binding Documents

+ {#if bindingDocumentsLoading} +

Loading binding documents...

+ {:else if bindingDocumentsError} +

{bindingDocumentsError}

+ + {:else} +
+
+

Photographs

+ {#if photoDocuments.length === 0} +

No photograph binding documents.

+ {:else} +
+ {#each photoDocuments as doc} + {@const photoSrc = typeof doc.data?.photoBlob === 'string' && doc.data.photoBlob ? doc.data.photoBlob : null} +
+

ID: {doc.id}

+ {#if photoSrc} + Binding + {:else} +
+ No image +
+ {/if} +
+ {/each} +
+ {/if} +
+ +
+

ID Document

+ {#if idDocuments.length === 0} +

No id_document binding documents.

+ {:else} + + + + + + + + + + {#each idDocuments as doc} + + + + + + {/each} + +
VendorReferenceName
{getDataValue(doc.data, 'vendor')}{getDataValue(doc.data, 'reference')}{getDataValue(doc.data, 'name')}
+ {/if} +
+ +
+

Self Binding

+ {#if selfDocuments.length === 0} +

No self binding documents.

+ {:else} + + + + + + + + + {#each selfDocuments as doc} + + + + + {/each} + +
NameSubject
{getDataValue(doc.data, 'name')}{doc.subject}
+ {/if} +
+
+ +
+
+

Social Connections

+ {#if socialConnections.length === 0} +

No social connections found.

+ {:else} +
+ {#each socialConnections as connection} +
+

{connection.name}

+

+ Witness eName: {connection.witnessEName || 'Unknown'} +

+ {#if connection.relationDescription} +

+ {connection.relationDescription} +

+ {/if} +
+ {/each} +
+ {/if} +
+
+ {/if} +
{/if} {/if} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 000000000..dc7ba32fe --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,29 @@ +{ + "name": "@metastate-foundation/types", + "version": "0.1.0", + "description": "Shared W3DS types for binding documents and related structures", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check-types": "tsc --noEmit" + }, + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./binding-document": { + "types": "./dist/binding-document.d.ts", + "import": "./dist/binding-document.js" + } + }, + "files": [ + "dist" + ], + "devDependencies": { + "typescript": "~5.6.2" + } +} diff --git a/packages/types/src/binding-document.ts b/packages/types/src/binding-document.ts new file mode 100644 index 000000000..f76131148 --- /dev/null +++ b/packages/types/src/binding-document.ts @@ -0,0 +1,38 @@ +export type BindingDocumentType = + | "id_document" + | "photograph" + | "social_connection" + | "self"; + +export interface BindingDocumentSignature { + signer: string; + signature: string; + timestamp: string; +} + +export interface BindingDocument { + id: string; + subject: string; + type: BindingDocumentType; + data: Record; + signatures: BindingDocumentSignature[]; +} + +export interface SocialConnection { + id: string; + name: string; + witnessEName: string | null; + relationDescription?: string | null; + signatures?: BindingDocumentSignature[]; +} + +export interface WitnessSession { + id: string; + targetEName: string; + expectedWitnessEName: string; + status: "pending" | "witnessed" | "expired" | "rejected"; + signature?: string; + witnessedBy?: string; + createdAt: string; + expiresAt: string; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 000000000..a880380b7 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,7 @@ +export type { + BindingDocument, + BindingDocumentSignature, + BindingDocumentType, + SocialConnection, + WitnessSession, +} from "./binding-document.js"; diff --git a/packages/types/tsconfig.build.json b/packages/types/tsconfig.build.json new file mode 100644 index 000000000..8d6db0f0a --- /dev/null +++ b/packages/types/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 000000000..67aa78d12 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/platforms/enotary/package.json b/platforms/enotary/package.json index 2cfebf289..1a06e629f 100644 --- a/platforms/enotary/package.json +++ b/platforms/enotary/package.json @@ -21,8 +21,9 @@ "typescript": "^5.0.0", "vite": "^6.2.6" }, - "dependencies": { - "axios": "^1.12.2", + "dependencies": { + "@metastate-foundation/types": "workspace:*", + "axios": "^1.12.2", "graphql-request": "^7.3.1", "signature-validator": "workspace:*", "svelte-qrcode": "^1.0.1" diff --git a/platforms/enotary/src/lib/server/evault.ts b/platforms/enotary/src/lib/server/evault.ts index 1d4a12414..12da6e46a 100644 --- a/platforms/enotary/src/lib/server/evault.ts +++ b/platforms/enotary/src/lib/server/evault.ts @@ -2,17 +2,21 @@ import axios from "axios"; import { GraphQLClient, gql } from "graphql-request"; import { env } from "$env/dynamic/private"; import { PUBLIC_REGISTRY_URL } from "$env/static/public"; -import type { BindingDocument, SocialConnection } from "./types"; +import type { BindingDocument, SocialConnection } from "@metastate-foundation/types"; const BINDING_DOCUMENTS_QUERY = gql` - query GetBindingDocuments($first: Int!) { - bindingDocuments(first: $first) { + query GetBindingDocuments($first: Int!, $after: String) { + bindingDocuments(first: $first, after: $after) { edges { node { id parsed } } + pageInfo { + hasNextPage + endCursor + } } } `; @@ -48,6 +52,10 @@ interface BindingDocumentsResponse { parsed: Record | null; }; }>; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; }; } @@ -151,12 +159,23 @@ class EvaultService { }, }); - const response = await client.request( - BINDING_DOCUMENTS_QUERY, - { first: 100 }, - ); + const allEdges: Array<{ + node: { id: string; parsed: Record | null }; + }> = []; + let afterCursor: string | null = null; - const documents: BindingDocument[] = response.bindingDocuments.edges + do { + const res: BindingDocumentsResponse = await client.request( + BINDING_DOCUMENTS_QUERY, + { first: 100, after: afterCursor ?? undefined }, + ); + allEdges.push(...res.bindingDocuments.edges); + afterCursor = res.bindingDocuments.pageInfo.hasNextPage + ? res.bindingDocuments.pageInfo.endCursor + : null; + } while (afterCursor !== null); + + const documents: BindingDocument[] = allEdges .map((edge) => { const parsed = edge.node.parsed; if (!parsed || typeof parsed !== "object") return null; @@ -193,12 +212,23 @@ class EvaultService { if (!otherPartyEName) return null; - const name = await this.resolveDisplayNameForEName(otherPartyEName); + let name: string; + try { + name = await this.resolveDisplayNameForEName(otherPartyEName); + } catch { + name = otherPartyEName; + } + + const relationDescription = + typeof doc.data?.relation_description === "string" + ? doc.data.relation_description + : undefined; return { id: doc.id, name, witnessEName: otherPartyEName, + relationDescription, signatures: doc.signatures, }; }), diff --git a/platforms/enotary/src/lib/server/types.ts b/platforms/enotary/src/lib/server/types.ts deleted file mode 100644 index 41cfe85e7..000000000 --- a/platforms/enotary/src/lib/server/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type BindingDocumentType = - | "id_document" - | "photograph" - | "social_connection" - | "self"; - -export interface BindingDocumentSignature { - signer: string; - signature: string; - timestamp: string; -} - -export interface BindingDocument { - id: string; - subject: string; - type: BindingDocumentType; - data: Record; - signatures: BindingDocumentSignature[]; -} - -export interface SocialConnection { - id: string; - name: string; - witnessEName: string | null; - signatures: BindingDocumentSignature[]; -} - -export interface WitnessSession { - id: string; - targetEName: string; - expectedWitnessEName: string; - status: "pending" | "witnessed" | "expired" | "rejected"; - signature?: string; - witnessedBy?: string; - createdAt: string; - expiresAt: string; -} diff --git a/platforms/enotary/src/lib/server/witness.ts b/platforms/enotary/src/lib/server/witness.ts index e957c714f..036d42a1f 100644 --- a/platforms/enotary/src/lib/server/witness.ts +++ b/platforms/enotary/src/lib/server/witness.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { PUBLIC_REGISTRY_URL } from "$env/static/public"; import { verifySignature } from "signature-validator/src/index"; -import type { WitnessSession } from "./types"; +import type { WitnessSession } from "@metastate-foundation/types"; const SESSION_TTL_MS = 15 * 60 * 1000; diff --git a/platforms/enotary/src/routes/user/[ename]/+page.svelte b/platforms/enotary/src/routes/user/[ename]/+page.svelte index 9dee16f6f..c1b76aa1f 100644 --- a/platforms/enotary/src/routes/user/[ename]/+page.svelte +++ b/platforms/enotary/src/routes/user/[ename]/+page.svelte @@ -1,26 +1,7 @@