Skip to content

Commit 77fc34b

Browse files
committed
feat: control panel binding doc viewer
1 parent d2efd8b commit 77fc34b

17 files changed

Lines changed: 617 additions & 67 deletions

File tree

infrastructure/control-panel/config/admin-enames.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
"@82f7a77a-f03a-52aa-88fc-1b1e488ad498",
55
"@35a31f0d-dd76-5780-b383-29f219fcae99",
66
"@82f7a77a-f03a-52aa-88fc-1b1e488ad498",
7-
"@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c"
7+
"@6e1bbcd4-1f59-5bd8-aa3c-6f5301c356d7"
88
]
99
}

infrastructure/control-panel/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"vite": "^7.0.4"
4444
},
4545
"dependencies": {
46+
"graphql-request": "^7.3.1",
47+
"@metastate-foundation/types": "workspace:*",
4648
"@hugeicons/core-free-icons": "^1.0.13",
4749
"@hugeicons/svelte": "^1.0.2",
4850
"@inlang/paraglide-js": "^2.0.0",
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { GraphQLClient, gql } from 'graphql-request';
2+
import { PUBLIC_CONTROL_PANEL_URL, PUBLIC_REGISTRY_URL } from '$env/static/public';
3+
import type { BindingDocument, SocialConnection } from '@metastate-foundation/types';
4+
5+
const BINDING_DOCUMENTS_QUERY = gql`
6+
query GetBindingDocuments($first: Int!) {
7+
bindingDocuments(first: $first) {
8+
edges {
9+
node {
10+
id
11+
parsed
12+
}
13+
}
14+
}
15+
}
16+
`;
17+
18+
const USER_PROFILE_QUERY = gql`
19+
query GetUserProfile($ontologyId: ID!, $first: Int!) {
20+
metaEnvelopes(filter: { ontologyId: $ontologyId }, first: $first) {
21+
edges {
22+
node {
23+
parsed
24+
}
25+
}
26+
}
27+
}
28+
`;
29+
30+
const USER_PROFILE_ONTOLOGY = '550e8400-e29b-41d4-a716-446655440000';
31+
32+
interface RegistryResolveResponse {
33+
evaultUrl?: string;
34+
uri?: string;
35+
}
36+
37+
interface PlatformCertificationResponse {
38+
token: string;
39+
}
40+
41+
interface BindingDocumentsResponse {
42+
bindingDocuments: {
43+
edges: Array<{
44+
node: {
45+
id: string;
46+
parsed: Record<string, unknown> | null;
47+
};
48+
}>;
49+
};
50+
}
51+
52+
class EvaultService {
53+
private platformToken: string | null = null;
54+
private profileNameCache = new Map<string, string>();
55+
56+
private getRegistryUrl(): string {
57+
const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.w3ds.metastate.foundation';
58+
return registryUrl;
59+
}
60+
61+
private getGraphqlUrl(evaultBaseUrl: string): string {
62+
return new URL('/graphql', evaultBaseUrl).toString();
63+
}
64+
65+
normalizeEName(value: string): string {
66+
return value.startsWith('@') ? value : `@${value}`;
67+
}
68+
69+
private async getPlatformToken(): Promise<string> {
70+
if (this.platformToken) return this.platformToken;
71+
const platform = PUBLIC_CONTROL_PANEL_URL || 'control-panel';
72+
const endpoint = new URL('/platforms/certification', this.getRegistryUrl()).toString();
73+
const response = await fetch(endpoint, {
74+
method: 'POST',
75+
headers: { 'Content-Type': 'application/json' },
76+
body: JSON.stringify({ platform }),
77+
signal: AbortSignal.timeout(10_000)
78+
});
79+
80+
if (!response.ok) {
81+
throw new Error(`Failed to get platform token: HTTP ${response.status}`);
82+
}
83+
84+
const data = (await response.json()) as PlatformCertificationResponse;
85+
if (!data.token) {
86+
throw new Error('Failed to get platform token: missing token in response');
87+
}
88+
89+
this.platformToken = data.token;
90+
return this.platformToken;
91+
}
92+
93+
async resolveEVaultUrl(eName: string): Promise<string> {
94+
const normalized = this.normalizeEName(eName);
95+
const endpoint = new URL(
96+
`/resolve?w3id=${encodeURIComponent(normalized)}`,
97+
this.getRegistryUrl()
98+
).toString();
99+
100+
const response = await fetch(endpoint, {
101+
signal: AbortSignal.timeout(10_000)
102+
});
103+
104+
if (!response.ok) {
105+
throw new Error(`Registry resolve failed: HTTP ${response.status}`);
106+
}
107+
108+
const data = (await response.json()) as RegistryResolveResponse;
109+
const resolved = data.evaultUrl ?? data.uri;
110+
111+
if (!resolved) {
112+
throw new Error('Registry did not return an eVault URL');
113+
}
114+
115+
return resolved;
116+
}
117+
118+
private async resolveDisplayNameForEName(eName: string): Promise<string> {
119+
const normalized = this.normalizeEName(eName);
120+
const cached = this.profileNameCache.get(normalized);
121+
if (cached) return cached;
122+
123+
const [evaultBaseUrl, token] = await Promise.all([
124+
this.resolveEVaultUrl(normalized),
125+
this.getPlatformToken()
126+
]);
127+
128+
const client = new GraphQLClient(this.getGraphqlUrl(evaultBaseUrl), {
129+
headers: {
130+
Authorization: `Bearer ${token}`,
131+
'X-ENAME': normalized
132+
}
133+
});
134+
135+
const response = await client.request<{
136+
metaEnvelopes?: {
137+
edges?: Array<{ node?: { parsed?: Record<string, unknown> | null } }>;
138+
};
139+
}>(USER_PROFILE_QUERY, {
140+
ontologyId: USER_PROFILE_ONTOLOGY,
141+
first: 1
142+
});
143+
144+
const profile = response.metaEnvelopes?.edges?.[0]?.node?.parsed;
145+
const displayName =
146+
(typeof profile?.displayName === 'string' && profile.displayName) ||
147+
(typeof profile?.name === 'string' && profile.name) ||
148+
normalized;
149+
150+
this.profileNameCache.set(normalized, displayName);
151+
return displayName;
152+
}
153+
154+
async fetchBindingDocuments(eName: string): Promise<{
155+
eName: string;
156+
documents: BindingDocument[];
157+
socialConnections: SocialConnection[];
158+
}> {
159+
const normalized = this.normalizeEName(eName);
160+
const [evaultBaseUrl, token] = await Promise.all([
161+
this.resolveEVaultUrl(normalized),
162+
this.getPlatformToken()
163+
]);
164+
165+
const client = new GraphQLClient(this.getGraphqlUrl(evaultBaseUrl), {
166+
headers: {
167+
Authorization: `Bearer ${token}`,
168+
'X-ENAME': normalized
169+
}
170+
});
171+
172+
const response = await client.request<BindingDocumentsResponse>(
173+
BINDING_DOCUMENTS_QUERY,
174+
{ first: 100 }
175+
);
176+
177+
const documents: BindingDocument[] = response.bindingDocuments.edges
178+
.map((edge) => {
179+
const parsed = edge.node.parsed;
180+
if (!parsed || typeof parsed !== 'object') return null;
181+
const { subject, type, data, signatures } = parsed;
182+
if (
183+
typeof subject !== 'string' ||
184+
typeof type !== 'string' ||
185+
typeof data !== 'object' ||
186+
data === null ||
187+
!Array.isArray(signatures)
188+
) {
189+
return null;
190+
}
191+
return {
192+
id: edge.node.id,
193+
subject,
194+
type: type as BindingDocument['type'],
195+
data: data as Record<string, unknown>,
196+
signatures: signatures as BindingDocument['signatures']
197+
};
198+
})
199+
.filter((doc): doc is BindingDocument => doc !== null);
200+
201+
const socialCandidates = documents.filter(
202+
(doc) => doc.type === 'social_connection' && doc.signatures.length === 2
203+
);
204+
205+
const socialConnections = (
206+
await Promise.all(
207+
socialCandidates.map(async (doc) => {
208+
const otherPartyEName = doc.signatures.find(
209+
(signature) => signature.signer !== normalized
210+
)?.signer;
211+
212+
if (!otherPartyEName) return null;
213+
214+
const name = await this.resolveDisplayNameForEName(otherPartyEName);
215+
216+
return {
217+
id: doc.id,
218+
name,
219+
witnessEName: otherPartyEName,
220+
signatures: doc.signatures
221+
};
222+
})
223+
)
224+
).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
225+
226+
return { eName: normalized, documents, socialConnections };
227+
}
228+
}
229+
230+
export const evaultService = new EvaultService();

infrastructure/control-panel/src/lib/services/evaultService.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EVault } from '../../routes/api/evaults/+server';
2+
import type { BindingDocument, SocialConnection } from '@metastate-foundation/types';
23
import { cacheService } from './cacheService';
34

45
export class EVaultService {
@@ -115,6 +116,32 @@ export class EVaultService {
115116
}
116117
}
117118

119+
/**
120+
* Get binding documents for a specific eVault by evaultId
121+
*/
122+
static async getBindingDocuments(
123+
evaultId: string
124+
): Promise<{ documents: BindingDocument[]; socialConnections: SocialConnection[]; eName: string }> {
125+
try {
126+
const response = await fetch(
127+
`/api/evaults/${encodeURIComponent(evaultId)}/binding-documents`
128+
);
129+
if (!response.ok) {
130+
const data = await response.json().catch(() => ({}));
131+
throw new Error(data.error || `HTTP error! status: ${response.status}`);
132+
}
133+
const data = await response.json();
134+
return {
135+
documents: data.documents || [],
136+
socialConnections: data.socialConnections || [],
137+
eName: data.eName || ''
138+
};
139+
} catch (error) {
140+
console.error('Failed to fetch binding documents:', error);
141+
throw error;
142+
}
143+
}
144+
118145
/**
119146
* Get logs for a specific eVault by namespace and podName
120147
*/
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { json } from '@sveltejs/kit';
2+
import type { RequestHandler } from './$types';
3+
import { registryService } from '$lib/services/registry';
4+
import { evaultService } from '$lib/server/evault';
5+
6+
export const GET: RequestHandler = async ({ params }) => {
7+
const { evaultId } = params;
8+
9+
try {
10+
const evaults = await registryService.getEVaults();
11+
const vault = evaults.find((v) => v.evault === evaultId || v.ename === evaultId);
12+
13+
if (!vault) {
14+
return json({ error: `eVault '${evaultId}' not found in registry.` }, { status: 404 });
15+
}
16+
17+
const result = await evaultService.fetchBindingDocuments(vault.ename);
18+
return json(result);
19+
} catch (error) {
20+
const message =
21+
error instanceof Error ? error.message : 'Failed to fetch binding documents';
22+
return json({ error: message }, { status: 500 });
23+
}
24+
};

0 commit comments

Comments
 (0)