-
Notifications
You must be signed in to change notification settings - Fork 5
feat: control panel binding doc viewer #928
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
77fc34b
aa1f25f
d20bd97
25bb7cf
9625221
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the same registry fallback as the existing control-panel APIs.
🔧 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Platform token is cached indefinitely without TTL. The 🛡️ 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 |
||||||||||||||||||
|
|
||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject malformed
🛡️ 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;
}Also applies to: 225-227 🤖 Prompt for AI Agents |
||||||||||||||||||
| 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); | ||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
|
|
||||||||||||||||||
| return { eName: normalized, documents, socialConnections }; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export const evaultService = new EvaultService(); | ||||||||||||||||||
| 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 }); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 }); | ||
| } | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.