Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions infrastructure/control-panel/config/admin-enames.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
2 changes: 2 additions & 0 deletions infrastructure/control-panel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
258 changes: 258 additions & 0 deletions infrastructure/control-panel/src/lib/server/evault.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null;
};
}>;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
}

class EvaultService {
private platformToken: string | null = null;
private profileNameCache = new Map<string, string>();

private getRegistryUrl(): string {
const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.w3ds.metastate.foundation';
return registryUrl;
}
Comment on lines +64 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the same registry fallback as the existing control-panel APIs.

getRegistryUrl() falls back to https://registry.w3ds.metastate.foundation, but infrastructure/control-panel/src/routes/api/evaults/+server.ts:145-166 still falls back to https://registry.staging.metastate.foundation. If PUBLIC_REGISTRY_URL is unset, the binding-documents tab will resolve against a different registry than the rest of the eVault page and can return mismatched or empty data. Please share one fallback/helper here so these paths cannot drift again.

🔧 Minimal alignment
 private getRegistryUrl(): string {
-		const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.w3ds.metastate.foundation';
+		const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.staging.metastate.foundation';
 		return registryUrl;
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private getRegistryUrl(): string {
const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.w3ds.metastate.foundation';
return registryUrl;
}
private getRegistryUrl(): string {
const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.staging.metastate.foundation';
return registryUrl;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infrastructure/control-panel/src/lib/server/evault.ts` around lines 64 - 67,
getRegistryUrl() currently hardcodes a different fallback than the control-panel
API, causing inconsistent registry resolution; create a single shared constant
or helper (e.g., export const DEFAULT_REGISTRY_URL or function
getPublicRegistryUrl()) that returns PUBLIC_REGISTRY_URL ||
'https://registry.staging.metastate.foundation' and replace the hardcoded
fallback in the getRegistryUrl method and the other evaults API code (the
handler currently using 'https://registry.staging.metastate.foundation') to
import and use that shared symbol so both places always resolve to the same
fallback.


private getGraphqlUrl(evaultBaseUrl: string): string {
return new URL('/graphql', evaultBaseUrl).toString();
}

normalizeEName(value: string): string {
return value.startsWith('@') ? value : `@${value}`;
}

private async getPlatformToken(): Promise<string> {
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;
}
Comment on lines +77 to +99
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Platform token is cached indefinitely without TTL.

The platformToken is cached after the first successful fetch and never refreshed. If the token expires or becomes invalid, all subsequent requests will fail until the service restarts.

🛡️ Consider adding token expiry handling
 class EvaultService {
 	private platformToken: string | null = null;
+	private platformTokenExpiresAt: number = 0;
 	private profileNameCache = new Map<string, string>();

 	// ...

 	private async getPlatformToken(): Promise<string> {
-		if (this.platformToken) return this.platformToken;
+		const now = Date.now();
+		if (this.platformToken && now < this.platformTokenExpiresAt) {
+			return this.platformToken;
+		}
 		const platform = PUBLIC_CONTROL_PANEL_URL || 'control-panel';
 		const endpoint = new URL('/platforms/certification', this.getRegistryUrl()).toString();
 		// ... fetch logic ...

 		this.platformToken = data.token;
+		// Assume 1 hour validity; adjust based on actual token TTL
+		this.platformTokenExpiresAt = now + 55 * 60 * 1000;
 		return this.platformToken;
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infrastructure/control-panel/src/lib/server/evault.ts` around lines 77 - 99,
getPlatformToken currently caches this.platformToken forever; change it to cache
the token with an expiry and refresh when expired by updating getPlatformToken
to (1) parse an expiry/ttl from the PlatformCertificationResponse (e.g.,
expires_at or ttl) or, if absent, apply a safe default TTL, (2) store both
this.platformToken and a new this.platformTokenExpiresAt (timestamp), and (3) on
subsequent calls return the cached token only if Date.now() <
this.platformTokenExpiresAt, otherwise re-fetch and replace both fields; update
any types/interfaces (PlatformCertificationResponse) to reflect optional expiry
fields and ensure error branches still clear/avoid stale tokens.


async resolveEVaultUrl(eName: string): Promise<string> {
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<string> {
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<string, unknown> | 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<string, unknown> | null } }> = [];
let afterCursor: string | null = null;

do {
const res: BindingDocumentsResponse = await client.request<BindingDocumentsResponse>(
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)
) {
Comment on lines +198 to +205
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject malformed signatures entries before reading signer.

Array.isArray(signatures) still allows payloads like [null] or [{ signer: 1 }]. Line 225 then dereferences signature.signer, so one malformed stored document can fail the entire binding-documents response instead of being skipped.

🛡️ Validate the minimum signature shape up front
 			.map((edge) => {
 				const parsed = edge.node.parsed;
 				if (!parsed || typeof parsed !== 'object') return null;
 				const { subject, type, data, signatures } = parsed;
+				const hasValidSignatures =
+					Array.isArray(signatures) &&
+					signatures.every(
+						(signature) =>
+							typeof signature === 'object' &&
+							signature !== null &&
+							typeof (signature as { signer?: unknown }).signer === 'string'
+					);
 				if (
 					typeof subject !== 'string' ||
 					typeof type !== 'string' ||
 					typeof data !== 'object' ||
 					data === null ||
-					!Array.isArray(signatures)
+					!hasValidSignatures
 				) {
 					return null;
 				}
Based on learnings, binding documents are initially implemented for "dumb" read/write operations (storage and retrieval via BindingDocumentService), so this reader should treat stored payloads as untrusted.

Also applies to: 225-227

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infrastructure/control-panel/src/lib/server/evault.ts` around lines 198 -
205, The signatures array is only checked by Array.isArray and can contain nulls
or malformed entries, which leads to dereferencing signature.signer later (see
parsed destructure and the signature access around the signature.signer usage at
lines ~225-227); update the reader in evault.ts to validate each signature entry
up front by ensuring every element is a non-null object with a valid signer
(e.g., typeof entry === 'object' && entry !== null && typeof entry.signer ===
'string') and either filter out/skip invalid entries or reject the payload
before attempting to read signature.signer, so malformed stored documents cannot
crash the binding-documents response.

return null;
}
return {
id: edge.node.id,
subject,
type: type as BindingDocument['type'],
data: data as Record<string, unknown>,
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<typeof entry> => entry !== null);

return { eName: normalized, documents, socialConnections };
}
}

export const evaultService = new EvaultService();
27 changes: 27 additions & 0 deletions infrastructure/control-panel/src/lib/services/evaultService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
}
};
Loading
Loading