Skip to content

Commit 9c528c2

Browse files
sosweethamcoodos
andauthored
feat: control panel binding doc viewer (#928)
* feat: control panel binding doc viewer * fix: check build dependence * fix: satisfy coderabbit * fix: check * feat: list relation descriptions as well --------- Co-authored-by: Merul Dhiman <69296233+coodos@users.noreply.github.com>
1 parent 370c3f2 commit 9c528c2

17 files changed

Lines changed: 701 additions & 75 deletions

File tree

infrastructure/control-panel/package.json

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

214+
/**
215+
* Get binding documents for a specific eVault by evaultId
216+
*/
217+
static async getBindingDocuments(
218+
evaultId: string
219+
): Promise<{ documents: BindingDocument[]; socialConnections: SocialConnection[]; eName: string }> {
220+
try {
221+
const response = await fetch(
222+
`/api/evaults/${encodeURIComponent(evaultId)}/binding-documents`
223+
);
224+
if (!response.ok) {
225+
const data = await response.json().catch(() => ({}));
226+
throw new Error(data.error || `HTTP error! status: ${response.status}`);
227+
}
228+
const data = await response.json();
229+
return {
230+
documents: data.documents || [],
231+
socialConnections: data.socialConnections || [],
232+
eName: data.eName || ''
233+
};
234+
} catch (error) {
235+
console.error('Failed to fetch binding documents:', error);
236+
throw error;
237+
}
238+
}
239+
213240
/**
214241
* Get logs for a specific eVault by namespace and podName
215242
*/
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
if (evaults.length === 0) {
12+
return json(
13+
{ error: 'Registry unavailable: failed to fetch eVaults' },
14+
{ status: 502 }
15+
);
16+
}
17+
const vault = evaults.find((v) => v.evault === evaultId || v.ename === evaultId);
18+
if (!vault) {
19+
return json({ error: `eVault '${evaultId}' not found in registry.` }, { status: 404 });
20+
}
21+
22+
const result = await evaultService.fetchBindingDocuments(vault.ename);
23+
return json(result);
24+
} catch (error) {
25+
const message =
26+
error instanceof Error ? error.message : 'Failed to fetch binding documents';
27+
return json({ error: message }, { status: 500 });
28+
}
29+
};

0 commit comments

Comments
 (0)