diff --git a/infrastructure/control-panel/config/admin-enames.json b/infrastructure/control-panel/config/admin-enames.json
index 1827d693c..bc06288bc 100644
--- a/infrastructure/control-panel/config/admin-enames.json
+++ b/infrastructure/control-panel/config/admin-enames.json
@@ -4,6 +4,8 @@
"@82f7a77a-f03a-52aa-88fc-1b1e488ad498",
"@35a31f0d-dd76-5780-b383-29f219fcae99",
"@82f7a77a-f03a-52aa-88fc-1b1e488ad498",
- "@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c"
+ "@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c",
+ "@6e1bbcd4-1f59-5bd8-aa3c-6f5301c356d7",
+ "@b995a88a-90d1-56fc-ba42-1e1eb664861c"
]
}
diff --git a/infrastructure/control-panel/package.json b/infrastructure/control-panel/package.json
index e38357874..3813c274b 100644
--- a/infrastructure/control-panel/package.json
+++ b/infrastructure/control-panel/package.json
@@ -24,6 +24,7 @@
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
+ "@types/d3-shape": "^3.1.8",
"@types/node": "^22",
"@types/qrcode": "^1.5.6",
"eslint": "^9.18.0",
@@ -48,10 +49,12 @@
"@inlang/paraglide-js": "^2.0.0",
"@xyflow/svelte": "^1.2.2",
"clsx": "^2.1.1",
+ "d3-shape": "^3.2.0",
"flowbite": "^3.1.2",
"flowbite-svelte": "^1.10.7",
"flowbite-svelte-icons": "^2.2.1",
"jose": "^6.2.0",
+ "layercake": "^10.0.2",
"lowdb": "^7.0.1",
"lucide-svelte": "^0.561.0",
"qrcode": "^1.5.4",
diff --git a/infrastructure/control-panel/src/lib/components/charts/GroupContributionDonut.svelte b/infrastructure/control-panel/src/lib/components/charts/GroupContributionDonut.svelte
new file mode 100644
index 000000000..ee1894220
--- /dev/null
+++ b/infrastructure/control-panel/src/lib/components/charts/GroupContributionDonut.svelte
@@ -0,0 +1,154 @@
+
+
+
+
Contribution by sender
+
+ Share of scanned messages per sender (same counts as the table below)
+
+
+ {#if senderRows.some((r) => isSystemNoSenderRow(r) && r.messageCount > 0)}
+
+
+ Show system / no sender messages in chart
+
+ {/if}
+
+ {#if slices.length === 0}
+
No sender data to chart.
+ {:else}
+
+
+ s.id)}
+ yDomain={[0, Math.max(...slices.map((s) => s.value), 1)]}
+ padding={{ top: 8, right: 8, bottom: 8, left: 8 }}
+ >
+
+
+
+
+
+
+
+ {#each slices as s (s.id)}
+ {@const pct =
+ totalMessages > 0 ? Math.round((s.value / totalMessages) * 1000) / 10 : 0}
+
+
+
+
+ {#if s.evaultPageId}
+
{s.label}
+ {:else}
+ {s.label}
+ {/if}
+
+
{s.sub}
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/infrastructure/control-panel/src/lib/components/charts/GroupDonutArcs.svelte b/infrastructure/control-panel/src/lib/components/charts/GroupDonutArcs.svelte
new file mode 100644
index 000000000..4acd24c4d
--- /dev/null
+++ b/infrastructure/control-panel/src/lib/components/charts/GroupDonutArcs.svelte
@@ -0,0 +1,53 @@
+
+
+
+ {#each pieLayout($data ?? []) as a (a.data.id)}
+ {@const outerR = Math.min($width, $height) / 2 - 6}
+ {@const innerR = outerR * 0.56}
+ {@const d = arc>()
+ .innerRadius(innerR)
+ .outerRadius(outerR)
+ .cornerRadius(1.5)(a)}
+ {#if d}
+
+
+ {a.data.label} — {a.data.sub}: {a.data.value} messages (open list)
+
+
+ {/if}
+ {/each}
+
diff --git a/infrastructure/control-panel/src/lib/server/evault-graphql.ts b/infrastructure/control-panel/src/lib/server/evault-graphql.ts
new file mode 100644
index 000000000..ba4967fa2
--- /dev/null
+++ b/infrastructure/control-panel/src/lib/server/evault-graphql.ts
@@ -0,0 +1,402 @@
+import { PUBLIC_REGISTRY_URL } from '$env/static/public';
+import { registryService, type RegistryVault } from '$lib/services/registry';
+
+export const USER_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440000';
+/** Ontology id used when storing group manifests (web3-adapter, Blabsy/Pictique/charter group.mapping.json, MessageNotificationService). */
+export const GROUP_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440003';
+/** Alternate id from services/ontology/schemas/groupManifest.json — try if primary misses. */
+export const GROUP_ONTOLOGY_ID_LEGACY = 'a8bfb7cf-3200-4b25-9ea9-ee41100f212e';
+export const MESSAGE_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440004';
+
+const META_ENVELOPES_PARSED_QUERY = `
+ query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int) {
+ metaEnvelopes(filter: $filter, first: $first) {
+ edges {
+ node {
+ parsed
+ }
+ }
+ }
+ }
+`;
+
+const META_ENVELOPE_BY_ID_QUERY = `
+ query MetaEnvelopeById($id: ID!) {
+ metaEnvelope(id: $id) {
+ id
+ ontology
+ parsed
+ }
+ }
+`;
+
+const LEGACY_GET_META_ENVELOPE_BY_ID_QUERY = `
+ query LegacyGetMetaEnvelopeById($id: String!) {
+ getMetaEnvelopeById(id: $id) {
+ id
+ ontology
+ parsed
+ }
+ }
+`;
+
+/** Chat and GroupManifest share ontology 550e8400-...003; scan enough rows to find the manifest. */
+const GROUP_MANIFEST_SCAN_FIRST = 80;
+
+function parsedListFromMetaEnvelopesPayload(
+ payload: Record | null
+): Record[] {
+ if (!payload) {
+ return [];
+ }
+ const data = payload.data as Record | undefined;
+ const metaEnvelopes = data?.metaEnvelopes as Record | undefined;
+ const edges = metaEnvelopes?.edges as Array<{ node?: { parsed?: unknown } }> | undefined;
+ if (!edges?.length) {
+ return [];
+ }
+ const out: Record[] = [];
+ for (const edge of edges) {
+ const parsed = edge.node?.parsed;
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ out.push(parsed as Record);
+ }
+ }
+ return out;
+}
+
+export async function fetchParsedListByOntology(
+ vault: RegistryVault,
+ ontologyId: string,
+ token: string | undefined,
+ first: number
+): Promise[]> {
+ const payload = await evaultGraphqlPost({
+ uri: vault.uri,
+ ename: vault.ename,
+ query: META_ENVELOPES_PARSED_QUERY,
+ variables: {
+ filter: { ontologyId },
+ first
+ },
+ token,
+ timeoutMs: 12000
+ });
+ return parsedListFromMetaEnvelopesPayload(payload);
+}
+
+/** True when `parsed` looks like GroupManifest (not Chat: those use participantIds, not members). */
+export function isGroupManifestParsed(parsed: unknown): parsed is Record {
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return false;
+ }
+ const o = parsed as Record;
+ return Array.isArray(o.members) && typeof o.owner === 'string';
+}
+
+export type VaultIdentity = { name: string; type: 'user' | 'group' | 'unknown' };
+
+export async function requestPlatformToken(platform: string): Promise {
+ const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.staging.metastate.foundation';
+ const response = await fetch(new URL('/platforms/certification', registryUrl).toString(), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ platform }),
+ signal: AbortSignal.timeout(2500)
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to get platform token: HTTP ${response.status}`);
+ }
+
+ const data = (await response.json()) as { token?: string };
+ if (!data.token) {
+ throw new Error('Failed to get platform token: missing token in response');
+ }
+
+ return data.token;
+}
+
+export type EvaultGraphqlPostOptions = {
+ uri: string;
+ ename: string;
+ query: string;
+ variables?: Record;
+ token?: string;
+ timeoutMs?: number;
+};
+
+/**
+ * POST GraphQL to a vault. Tries Bearer token first when provided, then X-ENAME-only.
+ * Returns parsed JSON body or null on failure.
+ */
+export async function evaultGraphqlPost(
+ options: EvaultGraphqlPostOptions
+): Promise | null> {
+ const timeoutMs = options.timeoutMs ?? 2500;
+ const tryRequest = async (withAuth: boolean): Promise | null> => {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'X-ENAME': options.ename
+ };
+ if (withAuth && options.token) {
+ headers.Authorization = `Bearer ${options.token}`;
+ }
+
+ const response = await fetch(`${options.uri}/graphql`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ query: options.query,
+ variables: options.variables ?? {}
+ }),
+ signal: AbortSignal.timeout(timeoutMs)
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const payload = (await response.json()) as Record;
+ if (Array.isArray(payload?.errors) && (payload.errors as unknown[]).length > 0) {
+ return null;
+ }
+ return payload;
+ };
+
+ try {
+ const withAuth = await tryRequest(Boolean(options.token));
+ if (withAuth) {
+ return withAuth;
+ }
+ if (options.token) {
+ return await tryRequest(false);
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+function firstNonEmptyString(...values: unknown[]): string | null {
+ for (const value of values) {
+ if (typeof value === 'string' && value.trim().length > 0) {
+ return value.trim();
+ }
+ }
+ return null;
+}
+
+/** Dashboard-style label: user profile displayName/username, else fallbacks (e.g. ename, evault id). */
+export function displayNameFromUserProfile(
+ profile: Record | null | undefined,
+ ...fallbacks: unknown[]
+): string {
+ const fromProfile = firstNonEmptyString(profile?.displayName, profile?.name, profile?.username);
+ if (fromProfile) {
+ return fromProfile;
+ }
+ return firstNonEmptyString(...fallbacks) ?? 'Unknown';
+}
+
+export async function fetchFirstParsedByOntology(
+ vault: RegistryVault,
+ ontologyId: string,
+ token?: string
+): Promise | null> {
+ const payload = await evaultGraphqlPost({
+ uri: vault.uri,
+ ename: vault.ename,
+ query: META_ENVELOPES_PARSED_QUERY,
+ variables: {
+ filter: { ontologyId },
+ first: 1
+ },
+ token
+ });
+
+ if (!payload) {
+ return null;
+ }
+ const data = payload.data as Record | undefined;
+ const metaEnvelopes = data?.metaEnvelopes as Record | undefined;
+ const edges = metaEnvelopes?.edges as Array<{ node?: { parsed?: unknown } }> | undefined;
+ const parsed = edges?.[0]?.node?.parsed;
+
+ if (!parsed || typeof parsed !== 'object') {
+ return null;
+ }
+
+ return parsed as Record;
+}
+
+function readMetaEnvelopeNode(
+ payload: Record | null
+): { ontology: string; parsed: Record } | null {
+ if (!payload) {
+ return null;
+ }
+ const data = payload.data as Record | undefined;
+ const node =
+ (data?.metaEnvelope as Record | undefined | null) ??
+ (data?.getMetaEnvelopeById as Record | undefined | null);
+ if (!node || typeof node !== 'object') {
+ return null;
+ }
+ const ontology = typeof node.ontology === 'string' ? node.ontology : '';
+ const parsed = node.parsed;
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return null;
+ }
+ return { ontology, parsed: parsed as Record };
+}
+
+/**
+ * Load a single MetaEnvelope by id in the given vault context (X-ENAME + optional Bearer).
+ * Tries idiomatic `metaEnvelope(id)` then legacy `getMetaEnvelopeById`.
+ */
+export async function fetchMetaEnvelopeById(
+ vault: RegistryVault,
+ metaEnvelopeId: string,
+ token?: string
+): Promise<{ ontology: string; parsed: Record } | null> {
+ const tryIdiomatic = await evaultGraphqlPost({
+ uri: vault.uri,
+ ename: vault.ename,
+ query: META_ENVELOPE_BY_ID_QUERY,
+ variables: { id: metaEnvelopeId },
+ token,
+ timeoutMs: 8000
+ });
+ const fromIdiomatic = readMetaEnvelopeNode(tryIdiomatic);
+ if (fromIdiomatic) {
+ return fromIdiomatic;
+ }
+
+ const tryLegacy = await evaultGraphqlPost({
+ uri: vault.uri,
+ ename: vault.ename,
+ query: LEGACY_GET_META_ENVELOPE_BY_ID_QUERY,
+ variables: { id: metaEnvelopeId },
+ token,
+ timeoutMs: 8000
+ });
+ return readMetaEnvelopeNode(tryLegacy);
+}
+
+export function isUserOntologyId(ontologyField: string): boolean {
+ return ontologyField.trim().toLowerCase() === USER_ONTOLOGY_ID.toLowerCase();
+}
+
+/**
+ * Prefer a full GroupManifest among meta-envelopes with ontology 003 (Chat uses the same id).
+ * Otherwise return the first matching row (e.g. chat) for name/display fallbacks.
+ */
+export async function fetchGroupManifestOrFallbackParsed(
+ vault: RegistryVault,
+ token?: string
+): Promise | null> {
+ const primaryList = await fetchParsedListByOntology(
+ vault,
+ GROUP_ONTOLOGY_ID,
+ token,
+ GROUP_MANIFEST_SCAN_FIRST
+ );
+ const manifestHit = primaryList.find((p) => isGroupManifestParsed(p));
+ if (manifestHit) {
+ return manifestHit;
+ }
+ if (primaryList.length > 0) {
+ return primaryList[0];
+ }
+
+ const legacyList = await fetchParsedListByOntology(
+ vault,
+ GROUP_ONTOLOGY_ID_LEGACY,
+ token,
+ GROUP_MANIFEST_SCAN_FIRST
+ );
+ const legacyManifest = legacyList.find((p) => isGroupManifestParsed(p));
+ if (legacyManifest) {
+ return legacyManifest;
+ }
+ return legacyList[0] ?? null;
+}
+
+export async function resolveVaultIdentity(
+ vault: RegistryVault,
+ token?: string
+): Promise {
+ const defaultName = firstNonEmptyString(vault.ename, vault.evault, 'Unknown') || 'Unknown';
+
+ const userProfile = await fetchFirstParsedByOntology(vault, USER_ONTOLOGY_ID, token);
+ if (userProfile) {
+ return {
+ type: 'user',
+ name:
+ firstNonEmptyString(
+ userProfile.displayName,
+ userProfile.name,
+ userProfile.username,
+ vault.ename,
+ vault.evault
+ ) || defaultName
+ };
+ }
+
+ const groupManifest = await fetchGroupManifestOrFallbackParsed(vault, token);
+ if (groupManifest) {
+ return {
+ type: 'group',
+ name:
+ firstNonEmptyString(
+ groupManifest.name,
+ groupManifest.displayName,
+ groupManifest.title,
+ groupManifest.eName,
+ groupManifest.ename,
+ vault.ename,
+ vault.evault
+ ) || defaultName
+ };
+ }
+
+ return {
+ type: 'unknown',
+ name: defaultName
+ };
+}
+
+/** One row in the control-panel eVault list (registry + resolved identity). */
+export interface RegistryEvaultRow {
+ id: string;
+ name: string;
+ type: 'user' | 'group' | 'unknown';
+ ename: string;
+ uri: string;
+ evault: string;
+ status: string;
+ serviceUrl?: string;
+}
+
+export async function fetchRegistryEvaultRows(token?: string): Promise {
+ const registryVaults = await registryService.getEVaults();
+ return Promise.all(
+ registryVaults.map(async (vault) => {
+ const evaultId = vault.evault || vault.ename;
+ const identity = await resolveVaultIdentity(vault, token);
+ return {
+ id: evaultId,
+ name: identity.name,
+ type: identity.type,
+ ename: vault.ename,
+ uri: vault.uri,
+ evault: vault.evault,
+ status: 'Unknown',
+ serviceUrl: vault.uri
+ };
+ })
+ );
+}
diff --git a/infrastructure/control-panel/src/lib/server/group-message-buckets.ts b/infrastructure/control-panel/src/lib/server/group-message-buckets.ts
new file mode 100644
index 000000000..fe11329d9
--- /dev/null
+++ b/infrastructure/control-panel/src/lib/server/group-message-buckets.ts
@@ -0,0 +1,237 @@
+import type { RegistryVault } from '$lib/services/registry';
+
+/** Literal bucket key for messages with no sender id (must match group-insights aggregation). */
+export const NO_SENDER_BUCKET = '__no_sender__';
+
+/** Match dashboard / monitoring: ignore leading @ and case when comparing W3IDs. */
+export function normalizeW3id(s: string): string {
+ return s.trim().replace(/^@+/u, '').toLowerCase();
+}
+
+const UUID_RE =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+export function isLikelyUuid(s: string): boolean {
+ return UUID_RE.test(s.trim());
+}
+
+/**
+ * Web3-adapter stores relations as `user(firebaseOrGlobalId)` in some paths; registry keys are often the inner id.
+ * Peel wrappers so sender buckets line up with registry `evault` / mapping global ids.
+ */
+export function unwrapRelationIdDeep(raw: string | null | undefined): string | null {
+ if (raw == null || typeof raw !== 'string') {
+ return null;
+ }
+ let t = raw.trim();
+ if (!t) {
+ return null;
+ }
+ for (let i = 0; i < 4; i++) {
+ const m = /^(\w+)\(([^)]+)\)$/.exec(t);
+ if (!m) {
+ break;
+ }
+ const inner = m[2].trim();
+ if (!inner || inner === t) {
+ break;
+ }
+ t = inner;
+ }
+ return t || null;
+}
+
+/** Build evault id / raw ename → canonical registry `ename` for resolving message senders. */
+export function buildRegistryEnameLookup(vaults: RegistryVault[]): Map {
+ const map = new Map();
+ for (const v of vaults) {
+ const en = typeof v.ename === 'string' && v.ename.trim() ? v.ename.trim() : '';
+ if (!en) {
+ continue;
+ }
+ map.set(en, en);
+ map.set(en.toLowerCase(), en);
+ const nw = normalizeW3id(en);
+ if (nw) {
+ map.set(nw, en);
+ }
+ if (v.evault) {
+ map.set(v.evault, en);
+ map.set(v.evault.toLowerCase(), en);
+ }
+ }
+ return map;
+}
+
+function firstNonEmptyStringField(
+ parsed: Record,
+ keys: string[]
+): string | null {
+ for (const k of keys) {
+ const v = parsed[k];
+ if (typeof v === 'string' && v.trim().length > 0) {
+ return v.trim();
+ }
+ }
+ return null;
+}
+
+/** Raw sender id from payload (UUID / legacy), not used as the aggregation key when registry resolves. */
+export function rawMessageSenderId(parsed: Record | null | undefined): string | null {
+ if (!parsed || typeof parsed !== 'object') {
+ return null;
+ }
+ for (const v of [parsed.senderId, parsed.sender_id, parsed.userId]) {
+ if (typeof v === 'string' && v.trim().length > 0) {
+ return unwrapRelationIdDeep(v.trim());
+ }
+ if (typeof v === 'number' && Number.isFinite(v)) {
+ return String(v);
+ }
+ }
+ return null;
+}
+
+/**
+ * Bucket key for counts: W3ID eName when possible (manifest + UI use @…).
+ * 1) eName fields on the message payload
+ * 2) registry lookup: senderId / userId as evault id
+ */
+export function resolveMessageSenderEname(
+ parsed: Record | null | undefined,
+ registryLookup: Map
+): string | null {
+ if (!parsed || typeof parsed !== 'object') {
+ return null;
+ }
+ const fromPayload = firstNonEmptyStringField(parsed, [
+ 'senderEname',
+ 'sender_ename',
+ 'senderEName',
+ 'eName',
+ 'ename',
+ 'senderW3id',
+ 'w3id'
+ ]);
+ if (fromPayload) {
+ return (
+ registryLookup.get(fromPayload) ??
+ registryLookup.get(fromPayload.toLowerCase()) ??
+ registryLookup.get(normalizeW3id(fromPayload)) ??
+ fromPayload
+ );
+ }
+ const rawId = rawMessageSenderId(parsed);
+ if (!rawId) {
+ return null;
+ }
+ return (
+ registryLookup.get(rawId) ??
+ registryLookup.get(rawId.toLowerCase()) ??
+ registryLookup.get(normalizeW3id(rawId)) ??
+ null
+ );
+}
+
+/** Stable aggregation key: canonical ename when resolvable, else raw sender id, else system bucket. */
+export function senderBucketKey(
+ parsed: Record | null | undefined,
+ registryLookup: Map
+): string {
+ const ename = resolveMessageSenderEname(parsed, registryLookup);
+ if (ename) {
+ return ename;
+ }
+ const raw = rawMessageSenderId(parsed);
+ if (raw) {
+ return raw;
+ }
+ return NO_SENDER_BUCKET;
+}
+
+export function previewMessageBody(parsed: Record | undefined): string | undefined {
+ if (!parsed) {
+ return undefined;
+ }
+ for (const key of ['text', 'content', 'body'] as const) {
+ const v = parsed[key];
+ if (typeof v === 'string' && v.length > 0) {
+ const oneLine = v.replace(/\s+/g, ' ').trim();
+ return oneLine.length > 160 ? `${oneLine.slice(0, 160)}…` : oneLine;
+ }
+ }
+ return undefined;
+}
+
+export function findSenderVaultForBucket(
+ bucketKey: string,
+ lookup: Map,
+ vaults: RegistryVault[]
+): RegistryVault | null {
+ const trimmed = bucketKey.trim();
+ const peeled = unwrapRelationIdDeep(trimmed);
+ if (peeled && peeled !== trimmed) {
+ const nested = findSenderVaultForBucket(peeled, lookup, vaults);
+ if (nested) {
+ return nested;
+ }
+ }
+ const lower = trimmed.toLowerCase();
+
+ const byEvault = vaults.find((v) => v.evault === trimmed);
+ if (byEvault) {
+ return byEvault;
+ }
+ const byEvaultCi = vaults.find((v) => (v.evault ?? '').toLowerCase() === lower);
+ if (byEvaultCi) {
+ return byEvaultCi;
+ }
+
+ const canonFromLookup =
+ lookup.get(trimmed) ?? lookup.get(lower) ?? lookup.get(normalizeW3id(trimmed));
+ if (canonFromLookup) {
+ const v = vaults.find(
+ (x) =>
+ x.ename === canonFromLookup ||
+ normalizeW3id(x.ename) === normalizeW3id(canonFromLookup)
+ );
+ if (v) {
+ return v;
+ }
+ }
+
+ const bucketNorm = normalizeW3id(trimmed);
+ if (bucketNorm) {
+ const byNorm = vaults.find((x) => normalizeW3id(x.ename) === bucketNorm);
+ if (byNorm) {
+ return byNorm;
+ }
+ }
+
+ return (
+ vaults.find((x) => x.ename === trimmed || x.ename.toLowerCase() === lower) ?? null
+ );
+}
+
+/** Segment for `/evaults/[id]` — matches client list `id === evault || ename`. */
+export function evaultPageIdForRow(
+ v: RegistryVault | null,
+ enameCol: string,
+ bucketKey: string
+): string | null {
+ if (v) {
+ const link = (v.evault?.trim() || v.ename?.trim()) ?? '';
+ if (link) {
+ return link;
+ }
+ }
+ const en = enameCol.trim();
+ if (en && en !== '—' && !isLikelyUuid(en)) {
+ return en;
+ }
+ const bk = bucketKey.trim();
+ if (bk && bk !== NO_SENDER_BUCKET && !isLikelyUuid(bk)) {
+ return bk;
+ }
+ return null;
+}
diff --git a/infrastructure/control-panel/src/lib/server/group-sender-resolve.server.ts b/infrastructure/control-panel/src/lib/server/group-sender-resolve.server.ts
new file mode 100644
index 000000000..d4b17cf75
--- /dev/null
+++ b/infrastructure/control-panel/src/lib/server/group-sender-resolve.server.ts
@@ -0,0 +1,346 @@
+import { cacheService } from '$lib/services/cacheService';
+import { type RegistryVault } from '$lib/services/registry';
+import {
+ displayNameFromUserProfile,
+ fetchMetaEnvelopeById,
+ isUserOntologyId,
+ resolveVaultIdentity,
+ type RegistryEvaultRow
+} from '$lib/server/evault-graphql';
+import {
+ evaultPageIdForRow,
+ findSenderVaultForBucket,
+ isLikelyUuid,
+ normalizeW3id,
+ NO_SENDER_BUCKET,
+ unwrapRelationIdDeep
+} from '$lib/server/group-message-buckets';
+
+const PROBE_VAULT_CONCURRENCY = 6;
+const MAX_USER_VAULTS_TO_PROBE = 150;
+
+/** Same `name` field as the dashboard table (`fetchRegistryEvaultRows`), keyed every way we might see a sender id. */
+export function buildDashboardNameByKey(rows: RegistryEvaultRow[]): Map {
+ const m = new Map();
+ for (const row of rows) {
+ const name = typeof row.name === 'string' ? row.name.trim() : '';
+ if (!name) {
+ continue;
+ }
+ const add = (k: string | undefined | null) => {
+ if (k == null || typeof k !== 'string') {
+ return;
+ }
+ let t = k.trim();
+ if (!t) {
+ return;
+ }
+ for (let depth = 0; depth < 6 && t; depth++) {
+ m.set(t, name);
+ m.set(t.toLowerCase(), name);
+ const nw = normalizeW3id(t);
+ if (nw) {
+ m.set(nw, name);
+ }
+ const inner = /^(\w+)\(([^)]+)\)$/.exec(t);
+ t = inner ? inner[2].trim() : '';
+ }
+ };
+ add(row.evault);
+ add(row.ename);
+ add(row.id);
+ }
+ return m;
+}
+
+export function recordDisplayHint(
+ byBucket: Map>,
+ bucket: string,
+ hint: string | null
+): void {
+ if (!hint?.trim()) {
+ return;
+ }
+ const h = hint.trim();
+ if (h.length < 2 || isLikelyUuid(h)) {
+ return;
+ }
+ let counts = byBucket.get(bucket);
+ if (!counts) {
+ counts = new Map();
+ byBucket.set(bucket, counts);
+ }
+ counts.set(h, (counts.get(h) ?? 0) + 1);
+}
+
+function pickBestDisplayHint(counts: Map | undefined): string | null {
+ if (!counts?.size) {
+ return null;
+ }
+ let best: string | null = null;
+ let bestN = 0;
+ for (const [k, n] of counts) {
+ if (n > bestN) {
+ bestN = n;
+ best = k;
+ }
+ }
+ return best;
+}
+
+function collectLookupKeys(...parts: (string | null | undefined)[]): string[] {
+ const keys: string[] = [];
+ const seen = new Set();
+ const addChain = (seed: string | null | undefined) => {
+ if (!seed?.trim()) {
+ return;
+ }
+ let t = seed.trim();
+ for (let depth = 0; depth < 6 && t; depth++) {
+ if (!seen.has(t)) {
+ seen.add(t);
+ keys.push(t);
+ }
+ const tl = t.toLowerCase();
+ if (!seen.has(tl)) {
+ seen.add(tl);
+ keys.push(tl);
+ }
+ const nw = normalizeW3id(t);
+ if (nw && !seen.has(nw)) {
+ seen.add(nw);
+ keys.push(nw);
+ }
+ const inner = /^(\w+)\(([^)]+)\)$/.exec(t);
+ t = inner ? inner[2].trim() : '';
+ }
+ };
+ for (const p of parts) {
+ addChain(p);
+ }
+ return keys;
+}
+
+export function userRegistryRowsForProbe(rows: RegistryEvaultRow[]): RegistryEvaultRow[] {
+ return rows.filter(
+ (r) =>
+ r.type === 'user' &&
+ !(typeof r.name === 'string' && /platform$/i.test(r.name.trim()))
+ );
+}
+
+function registryRowToVault(row: RegistryEvaultRow): RegistryVault {
+ return { ename: row.ename, uri: row.uri, evault: row.evault };
+}
+
+async function probeUserVaultsForSenderMetaEnvelopeId(
+ metaEnvelopeId: string,
+ userRows: RegistryEvaultRow[],
+ token: string | undefined,
+ probeRequestCache: Map
+): Promise {
+ if (!isLikelyUuid(metaEnvelopeId)) {
+ return null;
+ }
+ const memo = probeRequestCache.get(metaEnvelopeId);
+ if (memo !== undefined) {
+ return memo;
+ }
+
+ const ttlHit = cacheService.getCachedSenderProfileDisplayName(metaEnvelopeId);
+ if (ttlHit !== undefined) {
+ probeRequestCache.set(metaEnvelopeId, ttlHit);
+ return ttlHit;
+ }
+
+ const limited = userRows.slice(0, MAX_USER_VAULTS_TO_PROBE);
+ if (userRows.length > MAX_USER_VAULTS_TO_PROBE) {
+ console.warn(
+ '[group-sender-resolve] sender profile probe capped at',
+ MAX_USER_VAULTS_TO_PROBE,
+ 'user vaults (registry has',
+ userRows.length,
+ ')'
+ );
+ }
+
+ for (let i = 0; i < limited.length; i += PROBE_VAULT_CONCURRENCY) {
+ const chunk = limited.slice(i, i + PROBE_VAULT_CONCURRENCY);
+ const names = await Promise.all(
+ chunk.map(async (row) => {
+ const vault = registryRowToVault(row);
+ const me = await fetchMetaEnvelopeById(vault, metaEnvelopeId, token);
+ if (!me || !isUserOntologyId(me.ontology)) {
+ return null;
+ }
+ return displayNameFromUserProfile(me.parsed, row.ename);
+ })
+ );
+ for (const name of names) {
+ if (name) {
+ cacheService.setCachedSenderProfileDisplayName(metaEnvelopeId, name);
+ probeRequestCache.set(metaEnvelopeId, name);
+ return name;
+ }
+ }
+ }
+
+ probeRequestCache.set(metaEnvelopeId, null);
+ return null;
+}
+
+async function resolveSenderDisplayName(
+ bucketKey: string,
+ v: RegistryVault | null,
+ enameCol: string,
+ token: string | undefined,
+ dashboardNameByKey: Map,
+ hintByBucket: Map>,
+ userRowsForProbe: RegistryEvaultRow[],
+ probeRequestCache: Map
+): Promise {
+ for (const k of collectLookupKeys(bucketKey, enameCol)) {
+ const hit = dashboardNameByKey.get(k);
+ if (hit) {
+ return hit;
+ }
+ }
+ if (v) {
+ const identity = await resolveVaultIdentity(v, token);
+ return identity.name;
+ }
+ const hint = pickBestDisplayHint(hintByBucket.get(bucketKey));
+ if (hint) {
+ return hint;
+ }
+ if (enameCol && enameCol !== bucketKey && !isLikelyUuid(enameCol)) {
+ return enameCol;
+ }
+ const trimmedBucket = bucketKey.trim();
+ if (isLikelyUuid(trimmedBucket)) {
+ const probed = await probeUserVaultsForSenderMetaEnvelopeId(
+ trimmedBucket,
+ userRowsForProbe,
+ token,
+ probeRequestCache
+ );
+ if (probed) {
+ return probed;
+ }
+ }
+ return bucketKey;
+}
+
+function firstNonEmptyStringField(
+ parsed: Record,
+ keys: string[]
+): string | null {
+ for (const k of keys) {
+ const v = parsed[k];
+ if (typeof v === 'string' && v.trim().length > 0) {
+ return v.trim();
+ }
+ }
+ return null;
+}
+
+export function displayHintFromMessage(parsed: Record | null | undefined): string | null {
+ if (!parsed || typeof parsed !== 'object') {
+ return null;
+ }
+ const top = firstNonEmptyStringField(parsed, [
+ 'senderName',
+ 'sender_display_name',
+ 'senderDisplayName',
+ 'authorName',
+ 'authorDisplayName',
+ 'fromUserName',
+ 'fromDisplayName'
+ ]);
+ if (top && !isLikelyUuid(top)) {
+ return top;
+ }
+ const sender = parsed.sender;
+ if (sender && typeof sender === 'object' && !Array.isArray(sender)) {
+ const s = sender as Record;
+ const nested = firstNonEmptyStringField(s, [
+ 'displayName',
+ 'display_name',
+ 'name',
+ 'username',
+ 'ename',
+ 'eName'
+ ]);
+ if (nested && !isLikelyUuid(nested)) {
+ return nested;
+ }
+ }
+ return null;
+}
+
+export type ResolveSenderRowFieldsArgs = {
+ bucketKey: string;
+ allVaults: RegistryVault[];
+ registryEnameLookup: Map;
+ token: string | undefined;
+ dashboardNameByKey: Map;
+ hintByBucket: Map>;
+ userRowsForProbe: RegistryEvaultRow[];
+ probeRequestCache: Map;
+};
+
+/** One sender row’s display fields (same rules as the group insights table). */
+export async function resolveSenderRowFields(
+ args: ResolveSenderRowFieldsArgs
+): Promise<{ displayName: string; ename: string; evaultPageId: string | null }> {
+ const {
+ bucketKey,
+ allVaults,
+ registryEnameLookup,
+ token,
+ dashboardNameByKey,
+ hintByBucket,
+ userRowsForProbe,
+ probeRequestCache
+ } = args;
+
+ if (bucketKey === NO_SENDER_BUCKET) {
+ return {
+ displayName: 'System / no sender',
+ ename: '—',
+ evaultPageId: null
+ };
+ }
+
+ const v = findSenderVaultForBucket(bucketKey, registryEnameLookup, allVaults);
+ const peeledBucket = unwrapRelationIdDeep(bucketKey.trim());
+ const enameFromPeel = peeledBucket
+ ? registryEnameLookup.get(peeledBucket) ??
+ registryEnameLookup.get(peeledBucket.toLowerCase()) ??
+ registryEnameLookup.get(normalizeW3id(peeledBucket))
+ : undefined;
+ const enameCol =
+ v?.ename?.trim() ??
+ registryEnameLookup.get(bucketKey.trim()) ??
+ registryEnameLookup.get(bucketKey.trim().toLowerCase()) ??
+ registryEnameLookup.get(normalizeW3id(bucketKey)) ??
+ enameFromPeel ??
+ bucketKey;
+
+ const displayName = await resolveSenderDisplayName(
+ bucketKey,
+ v,
+ enameCol,
+ token,
+ dashboardNameByKey,
+ hintByBucket,
+ userRowsForProbe,
+ probeRequestCache
+ );
+
+ return {
+ displayName,
+ ename: enameCol,
+ evaultPageId: evaultPageIdForRow(v, enameCol, bucketKey)
+ };
+}
diff --git a/infrastructure/control-panel/src/lib/services/cacheService.ts b/infrastructure/control-panel/src/lib/services/cacheService.ts
index fec831ad7..839c5341c 100644
--- a/infrastructure/control-panel/src/lib/services/cacheService.ts
+++ b/infrastructure/control-panel/src/lib/services/cacheService.ts
@@ -17,9 +17,13 @@ const defaultData: CacheData = {
isStale: true
};
+const DEFAULT_SENDER_PROFILE_TTL_MS = 10 * 60 * 1000;
+
class CacheService {
private db: any | null = null;
private isInitialized = false;
+ /** Server-only: MetaEnvelope id (message sender global id) → resolved display name; reduces cross-vault probes. */
+ private senderProfileDisplayByGlobalId = new Map();
constructor() {
// Only initialize on the server side
@@ -173,6 +177,38 @@ class CacheService {
await this.db.write();
}
}
+
+ /**
+ * In-memory TTL cache (Node only). Returns undefined if missing or expired.
+ */
+ getCachedSenderProfileDisplayName(globalMetaEnvelopeId: string): string | undefined {
+ if (typeof window !== 'undefined') {
+ return undefined;
+ }
+ const row = this.senderProfileDisplayByGlobalId.get(globalMetaEnvelopeId);
+ if (!row) {
+ return undefined;
+ }
+ if (Date.now() > row.expiresAt) {
+ this.senderProfileDisplayByGlobalId.delete(globalMetaEnvelopeId);
+ return undefined;
+ }
+ return row.value;
+ }
+
+ setCachedSenderProfileDisplayName(
+ globalMetaEnvelopeId: string,
+ displayName: string,
+ ttlMs: number = DEFAULT_SENDER_PROFILE_TTL_MS
+ ): void {
+ if (typeof window !== 'undefined' || !displayName.trim()) {
+ return;
+ }
+ this.senderProfileDisplayByGlobalId.set(globalMetaEnvelopeId, {
+ value: displayName.trim(),
+ expiresAt: Date.now() + ttlMs
+ });
+ }
}
// Export a singleton instance
diff --git a/infrastructure/control-panel/src/lib/services/evaultService.ts b/infrastructure/control-panel/src/lib/services/evaultService.ts
index 4c1f00118..5e07c3a15 100644
--- a/infrastructure/control-panel/src/lib/services/evaultService.ts
+++ b/infrastructure/control-panel/src/lib/services/evaultService.ts
@@ -1,6 +1,50 @@
import type { EVault } from '../../routes/api/evaults/+server';
import { cacheService } from './cacheService';
+/** Must match `NO_SENDER_BUCKET` in `$lib/server/group-message-buckets`. */
+export const GROUP_NO_SENDER_BUCKET_KEY = '__no_sender__';
+
+export type GroupSenderRow = {
+ /** User ontology display name / username, same idea as the dashboard. */
+ displayName: string;
+ /** W3ID ename when known, raw sender id when not in registry, or "—" for system/no sender. */
+ ename: string;
+ messageCount: number;
+ /** Aggregation key; pass as `bucket` to group messages API / messages page. */
+ bucketKey: string;
+ /** Target for `/evaults/[id]` when we can resolve a registry vault or non-UUID handle. */
+ evaultPageId: string | null;
+};
+
+export type GroupMessagesForSenderResponse = {
+ evault: { ename: string; uri: string; evault: string };
+ /** Group manifest display name (fallback: vault eName / route id). */
+ groupDisplayName: string;
+ senderDisplayName: string;
+ senderEname: string;
+ bucket: string;
+ messages: Array<{ id: string | null; preview: string | undefined; senderBucket: string }>;
+ matchedCount: number;
+ messagesScanned: number;
+ totalCount: number;
+ capped: boolean;
+};
+
+export type GroupInsights = {
+ evault: { ename: string; uri: string; evault: string };
+ manifest: Record;
+ messageStats: {
+ totalCount: number;
+ messagesScanned: number;
+ /** Messages bucketed to a sender id or resolvable ename (excludes null / missing sender). */
+ messagesWithSenderBucket: number;
+ /** Messages with no sender id in the payload. */
+ messagesWithoutSender: number;
+ capped: boolean;
+ senderRows: GroupSenderRow[];
+ };
+};
+
export class EVaultService {
/**
* Get eVaults - load from cache first, then fetch fresh data
@@ -115,6 +159,57 @@ export class EVaultService {
}
}
+ static async getGroupEVaults(): Promise {
+ try {
+ const response = await fetch('/api/evaults/groups');
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const data = await response.json();
+ return data.evaults || [];
+ } catch (error) {
+ console.error('Failed to fetch group eVaults:', error);
+ throw error;
+ }
+ }
+
+ static async getGroupInsights(evaultId: string): Promise {
+ try {
+ const response = await fetch(
+ `/api/evaults/${encodeURIComponent(evaultId)}/group-insights`
+ );
+ if (!response.ok) {
+ const errBody = await response.json().catch(() => ({}));
+ const message =
+ typeof errBody?.error === 'string' ? errBody.error : `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+ return (await response.json()) as GroupInsights;
+ } catch (error) {
+ console.error('Failed to fetch group insights:', error);
+ throw error;
+ }
+ }
+
+ static async getGroupMessagesForSender(
+ evaultId: string,
+ bucketKey: string
+ ): Promise {
+ const url = new URL(
+ `/api/evaults/${encodeURIComponent(evaultId)}/group-messages`,
+ window.location.origin
+ );
+ url.searchParams.set('bucket', bucketKey);
+ const response = await fetch(url.toString());
+ if (!response.ok) {
+ const errBody = await response.json().catch(() => ({}));
+ const message =
+ typeof errBody?.error === 'string' ? errBody.error : `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+ return (await response.json()) as GroupMessagesForSenderResponse;
+ }
+
/**
* Get logs for a specific eVault by namespace and podName
*/
diff --git a/infrastructure/control-panel/src/routes/+layout.svelte b/infrastructure/control-panel/src/routes/+layout.svelte
index f4bade69e..9e6115575 100644
--- a/infrastructure/control-panel/src/routes/+layout.svelte
+++ b/infrastructure/control-panel/src/routes/+layout.svelte
@@ -12,6 +12,7 @@
const navLinks = [
{ label: 'Dashboard', href: '/' },
+ { label: 'Groups', href: '/groups' },
{ label: 'Monitoring', href: '/monitoring' },
{ label: 'Actions', href: '/actions' },
{ label: 'Notifications', href: '/notifications' },
diff --git a/infrastructure/control-panel/src/routes/+page.svelte b/infrastructure/control-panel/src/routes/+page.svelte
index 1ed9b1885..1df24205d 100644
--- a/infrastructure/control-panel/src/routes/+page.svelte
+++ b/infrastructure/control-panel/src/routes/+page.svelte
@@ -5,7 +5,7 @@
import { registryService } from '$lib/services/registry';
import type { Platform } from '$lib/services/registry';
import { Table } from '$lib/ui';
- import { RefreshCw, UserRound, Users } from 'lucide-svelte';
+ import { CircleQuestionMark, RefreshCw, UserRound, Users } from 'lucide-svelte';
import { onMount } from 'svelte';
import type { EVault } from './api/evaults/+server';
@@ -81,7 +81,7 @@
type: 'snippet' as const,
value: {
ename: evault.ename || 'N/A',
- type: evault.type || 'group'
+ type: evault.type
},
snippet: ENameWithType,
width: 7
@@ -363,9 +363,12 @@
const paginated = paginatedEVaults();
const evault = paginated[index];
if (evault) {
- // Use evault ID (evault field or ename) for navigation
const evaultId = evault.evault || evault.ename || evault.id;
- goto(`/evaults/${encodeURIComponent(evaultId)}`);
+ if (evault.type === 'group') {
+ goto(`/groups/${encodeURIComponent(evaultId)}`);
+ } else {
+ goto(`/evaults/${encodeURIComponent(evaultId)}`);
+ }
}
}
@@ -463,14 +466,17 @@
{#snippet ENameWithType(value: unknown)}
- {@const item = (value as { ename?: string; type?: 'user' | 'group' } | null) ?? null}
+ {@const item = (value as { ename?: string; type?: 'user' | 'group' | 'unknown' } | null) ??
+ null}
{@const ename = item?.ename || 'N/A'}
- {@const type = item?.type || 'group'}
+ {@const type = item?.type ?? 'unknown'}
{#if type === 'user'}
- {:else}
+ {:else if type === 'group'}
+ {:else}
+
{/if}
{ename}
diff --git a/infrastructure/control-panel/src/routes/api/evaults/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/+server.ts
index 3dea2f0bd..e33a7d892 100644
--- a/infrastructure/control-panel/src/routes/api/evaults/+server.ts
+++ b/infrastructure/control-panel/src/routes/api/evaults/+server.ts
@@ -1,169 +1,13 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
-import { PUBLIC_CONTROL_PANEL_URL, PUBLIC_REGISTRY_URL } from '$env/static/public';
-import { registryService, type RegistryVault } from '$lib/services/registry';
+import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public';
+import {
+ fetchRegistryEvaultRows,
+ requestPlatformToken,
+ type RegistryEvaultRow
+} from '$lib/server/evault-graphql';
-const USER_ONTOLOGY_ID = '550e8400-e29b-41d4-a716-446655440000';
-const GROUP_ONTOLOGY_ID = 'a8bfb7cf-3200-4b25-9ea9-ee41100f212e';
-
-const META_ENVELOPES_QUERY = `
- query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int) {
- metaEnvelopes(filter: $filter, first: $first) {
- edges {
- node {
- parsed
- }
- }
- }
- }
-`;
-
-export interface EVault {
- id: string; // evault identifier (evault field from registry)
- name: string; // display name (ename or evault)
- type: 'user' | 'group'; // derived from ontology lookup
- ename: string; // w3id identifier
- uri: string; // resolved service URI
- evault: string; // evault identifier
- status: string; // derived from health check
- serviceUrl?: string; // same as uri for display
-}
-
-function firstNonEmptyString(...values: unknown[]): string | null {
- for (const value of values) {
- if (typeof value === 'string' && value.trim().length > 0) {
- return value.trim();
- }
- }
- return null;
-}
-
-async function fetchFirstParsedByOntology(
- vault: RegistryVault,
- ontologyId: string,
- token?: string
-): Promise | null> {
- const tryRequest = async (withAuth: boolean): Promise | null> => {
- const headers: Record = {
- 'Content-Type': 'application/json',
- 'X-ENAME': vault.ename
- };
- if (withAuth && token) {
- headers.Authorization = `Bearer ${token}`;
- }
-
- const response = await fetch(`${vault.uri}/graphql`, {
- method: 'POST',
- headers,
- body: JSON.stringify({
- query: META_ENVELOPES_QUERY,
- variables: {
- filter: { ontologyId },
- first: 1
- }
- }),
- signal: AbortSignal.timeout(2500)
- });
-
- if (!response.ok) {
- return null;
- }
-
- const payload = await response.json();
- if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
- return null;
- }
- const parsed = payload?.data?.metaEnvelopes?.edges?.[0]?.node?.parsed;
-
- if (!parsed || typeof parsed !== 'object') {
- return null;
- }
-
- return parsed as Record;
- };
-
- try {
- // Try with token first (preferred), then fallback to X-ENAME-only.
- const withAuth = await tryRequest(Boolean(token));
- if (withAuth) {
- return withAuth;
- }
-
- if (token) {
- return await tryRequest(false);
- }
-
- return null;
- } catch {
- return null;
- }
-}
-
-async function resolveVaultIdentity(
- vault: RegistryVault,
- token?: string
-): Promise<{ name: string; type: 'user' | 'group' }> {
- const defaultName = firstNonEmptyString(vault.ename, vault.evault, 'Unknown') || 'Unknown';
-
- const userProfile = await fetchFirstParsedByOntology(vault, USER_ONTOLOGY_ID, token);
- if (userProfile) {
- return {
- type: 'user',
- name:
- firstNonEmptyString(
- userProfile.displayName,
- userProfile.username,
- vault.ename,
- vault.evault
- ) || defaultName
- };
- }
-
- const groupManifest = await fetchFirstParsedByOntology(vault, GROUP_ONTOLOGY_ID, token);
- if (groupManifest) {
- return {
- type: 'group',
- name:
- firstNonEmptyString(
- groupManifest.name,
- groupManifest.displayName,
- groupManifest.title,
- groupManifest.eName,
- groupManifest.ename,
- vault.ename,
- vault.evault
- ) || defaultName
- };
- }
-
- return {
- type: 'group',
- name: defaultName
- };
-}
-
-async function requestPlatformToken(platform: string): Promise {
- const registryUrl = PUBLIC_REGISTRY_URL || 'https://registry.staging.metastate.foundation';
- const response = await fetch(new URL('/platforms/certification', registryUrl).toString(), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ platform }),
- signal: AbortSignal.timeout(2500)
- });
-
- if (!response.ok) {
- throw new Error(`Failed to get platform token: HTTP ${response.status}`);
- }
-
- const data = (await response.json()) as { token?: string };
- if (!data.token) {
- throw new Error('Failed to get platform token: missing token in response');
- }
-
- return data.token;
-}
+export type EVault = RegistryEvaultRow;
export const GET: RequestHandler = async ({ url }) => {
try {
@@ -175,29 +19,7 @@ export const GET: RequestHandler = async ({ url }) => {
console.warn('Falling back to X-ENAME-only eVault queries:', tokenError);
}
- // Fetch all evaults from registry
- const registryVaults = await registryService.getEVaults();
-
- // Transform registry vaults to EVault format
- const evaults: EVault[] = await Promise.all(
- registryVaults.map(async (vault) => {
- // Use evault identifier as the primary ID, fallback to ename
- const evaultId = vault.evault || vault.ename;
-
- const identity = await resolveVaultIdentity(vault, token);
-
- return {
- id: evaultId,
- name: identity.name,
- type: identity.type,
- ename: vault.ename,
- uri: vault.uri,
- evault: vault.evault,
- status: 'Unknown',
- serviceUrl: vault.uri
- };
- })
- );
+ const evaults: EVault[] = await fetchRegistryEvaultRows(token);
// Filter out platform-owned user profiles (e.g. "File Manager Platform").
const filteredEVaults = evaults.filter(
diff --git a/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts
new file mode 100644
index 000000000..8490d4292
--- /dev/null
+++ b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-insights/+server.ts
@@ -0,0 +1,299 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public';
+import { registryService, type RegistryVault } from '$lib/services/registry';
+import {
+ evaultGraphqlPost,
+ fetchGroupManifestOrFallbackParsed,
+ fetchRegistryEvaultRows,
+ MESSAGE_ONTOLOGY_ID,
+ requestPlatformToken,
+ type RegistryEvaultRow
+} from '$lib/server/evault-graphql';
+import {
+ buildRegistryEnameLookup,
+ NO_SENDER_BUCKET,
+ previewMessageBody,
+ rawMessageSenderId,
+ resolveMessageSenderEname,
+ senderBucketKey
+} from '$lib/server/group-message-buckets';
+import {
+ buildDashboardNameByKey,
+ displayHintFromMessage,
+ recordDisplayHint,
+ resolveSenderRowFields,
+ userRegistryRowsForProbe
+} from '$lib/server/group-sender-resolve.server';
+
+/**
+ * Sender display resolution lives in `$lib/server/group-sender-resolve.server.ts` (probe user vaults, dashboard names).
+ */
+
+const MESSAGES_PAGE_QUERY = `
+ query GroupMessages($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) {
+ metaEnvelopes(filter: $filter, first: $first, after: $after) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ node {
+ parsed
+ }
+ }
+ }
+ }
+`;
+
+const PAGE_SIZE = 100;
+const MAX_PAGES = 50;
+
+type MessageConnection = {
+ totalCount?: number;
+ pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
+ edges?: Array<{ node?: { parsed?: Record } }>;
+};
+
+function readMessageConnection(payload: Record | null): MessageConnection | null {
+ if (!payload) {
+ return null;
+ }
+ const data = payload.data as Record | undefined;
+ const conn = data?.metaEnvelopes as MessageConnection | undefined;
+ return conn ?? null;
+}
+
+type SenderRow = {
+ displayName: string;
+ ename: string;
+ messageCount: number;
+ bucketKey: string;
+ evaultPageId: string | null;
+};
+
+async function mapInChunks(items: T[], chunkSize: number, fn: (item: T) => Promise): Promise {
+ const out: R[] = [];
+ for (let i = 0; i < items.length; i += chunkSize) {
+ const chunk = items.slice(i, i + chunkSize);
+ out.push(...(await Promise.all(chunk.map(fn))));
+ }
+ return out;
+}
+
+async function buildSenderRows(
+ byBucket: Record,
+ registryEnameLookup: Map,
+ allVaults: RegistryVault[],
+ token: string | undefined,
+ dashboardNameByKey: Map,
+ hintByBucket: Map>,
+ userRowsForProbe: RegistryEvaultRow[],
+ probeRequestCache: Map
+): Promise {
+ const entries = Object.entries(byBucket).filter(([k]) => k !== NO_SENDER_BUCKET);
+ const rows = await mapInChunks(entries, 8, async ([bucketKey, messageCount]) => {
+ const { displayName, ename, evaultPageId } = await resolveSenderRowFields({
+ bucketKey,
+ allVaults,
+ registryEnameLookup,
+ token,
+ dashboardNameByKey,
+ hintByBucket,
+ userRowsForProbe,
+ probeRequestCache
+ });
+ return {
+ displayName,
+ ename,
+ messageCount,
+ bucketKey,
+ evaultPageId
+ };
+ });
+
+ rows.sort((a, b) => b.messageCount - a.messageCount);
+
+ const noSenderCount = byBucket[NO_SENDER_BUCKET] ?? 0;
+ if (noSenderCount > 0) {
+ rows.push({
+ displayName: 'System / no sender',
+ ename: '—',
+ messageCount: noSenderCount,
+ bucketKey: NO_SENDER_BUCKET,
+ evaultPageId: null
+ });
+ }
+
+ return rows;
+}
+
+/** Log one JSON line per scanned message. Set `CONTROL_PANEL_LOG_GROUP_MESSAGES=0` to disable. */
+const LOG_EACH_MESSAGE = process.env.CONTROL_PANEL_LOG_GROUP_MESSAGES !== '0';
+
+export const GET: RequestHandler = async ({ params, url }) => {
+ const evaultId = params.evaultId;
+
+ try {
+ const allVaults = await registryService.getEVaults();
+ const registryEnameLookup = buildRegistryEnameLookup(allVaults);
+
+ const vault = allVaults.find((v) => v.evault === evaultId || v.ename === evaultId);
+
+ if (!vault) {
+ return json({ error: `eVault '${evaultId}' not found in registry.` }, { status: 404 });
+ }
+
+ const platform = PUBLIC_CONTROL_PANEL_URL || url.origin;
+ let token: string | undefined;
+ try {
+ token = await requestPlatformToken(platform);
+ } catch (tokenError) {
+ console.warn('Group insights: no platform token:', tokenError);
+ }
+
+ let registryRows: RegistryEvaultRow[] = [];
+ try {
+ registryRows = await fetchRegistryEvaultRows(token);
+ } catch (rowsErr) {
+ console.warn('Group insights: fetchRegistryEvaultRows failed:', rowsErr);
+ }
+ const dashboardNameByKey = buildDashboardNameByKey(registryRows);
+ const hintByBucket = new Map>();
+
+ const manifest = await fetchGroupManifestOrFallbackParsed(vault, token);
+ if (!manifest) {
+ return json(
+ {
+ error:
+ 'Could not load group manifest from this vault (not a group eVault, unreachable, or auth failed).'
+ },
+ { status: 400 }
+ );
+ }
+
+ const byBucket: Record = {};
+ let messagesScanned = 0;
+ let totalCount = 0;
+ let after: string | undefined;
+ let capped = false;
+
+ for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex++) {
+ const variables: Record = {
+ filter: { ontologyId: MESSAGE_ONTOLOGY_ID },
+ first: PAGE_SIZE
+ };
+ if (after) {
+ variables.after = after;
+ }
+
+ const payload = await evaultGraphqlPost({
+ uri: vault.uri,
+ ename: vault.ename,
+ query: MESSAGES_PAGE_QUERY,
+ variables,
+ token,
+ timeoutMs: 15000
+ });
+
+ const conn = readMessageConnection(payload);
+ if (!conn) {
+ break;
+ }
+
+ if (pageIndex === 0 && typeof conn.totalCount === 'number') {
+ totalCount = conn.totalCount;
+ }
+
+ const edges = conn.edges ?? [];
+ for (const edge of edges) {
+ const parsed = edge.node?.parsed as Record | undefined;
+ const senderEname = resolveMessageSenderEname(parsed, registryEnameLookup);
+ const rawSenderId = rawMessageSenderId(parsed);
+ const bucket = senderBucketKey(parsed, registryEnameLookup);
+ byBucket[bucket] = (byBucket[bucket] ?? 0) + 1;
+ messagesScanned += 1;
+ recordDisplayHint(hintByBucket, bucket, displayHintFromMessage(parsed));
+
+ if (LOG_EACH_MESSAGE) {
+ console.log(
+ '[group-insights:message]',
+ JSON.stringify({
+ evaultId,
+ vaultEname: vault.ename,
+ index: messagesScanned,
+ senderBucket: bucket,
+ senderEname: senderEname ?? null,
+ senderId: rawSenderId ?? null,
+ id: parsed?.id ?? null,
+ chatId: parsed?.chatId ?? null,
+ isSystemMessage: parsed?.isSystemMessage ?? null,
+ preview: previewMessageBody(parsed),
+ parsedKeys: parsed && typeof parsed === 'object' ? Object.keys(parsed).sort() : []
+ })
+ );
+ }
+ }
+
+ const hasNext = conn.pageInfo?.hasNextPage === true;
+ const endCursor = conn.pageInfo?.endCursor;
+ if (!hasNext || edges.length === 0) {
+ break;
+ }
+ if (pageIndex === MAX_PAGES - 1) {
+ capped = true;
+ break;
+ }
+ after = endCursor ?? undefined;
+ }
+
+ const messagesWithoutSender = byBucket[NO_SENDER_BUCKET] ?? 0;
+ const messagesWithSenderBucket = Math.max(0, messagesScanned - messagesWithoutSender);
+ const userRowsForProbe = userRegistryRowsForProbe(registryRows);
+ const probeRequestCache = new Map();
+ const senderRows = await buildSenderRows(
+ byBucket,
+ registryEnameLookup,
+ allVaults,
+ token,
+ dashboardNameByKey,
+ hintByBucket,
+ userRowsForProbe,
+ probeRequestCache
+ );
+
+ console.log('[group-insights:summary]', {
+ evaultId,
+ vaultEname: vault.ename,
+ totalCount,
+ messagesScanned,
+ messagesWithSenderBucket,
+ messagesWithoutSender,
+ senderRowCount: senderRows.length,
+ capped,
+ byBucket,
+ perMessageLinesLogged: LOG_EACH_MESSAGE
+ });
+
+ return json({
+ evault: {
+ ename: vault.ename,
+ uri: vault.uri,
+ evault: vault.evault
+ },
+ manifest,
+ messageStats: {
+ totalCount,
+ messagesScanned,
+ messagesWithSenderBucket,
+ messagesWithoutSender,
+ capped,
+ senderRows
+ }
+ });
+ } catch (error) {
+ console.error('Error fetching group insights:', error);
+ return json({ error: 'Failed to fetch group insights' }, { status: 500 });
+ }
+};
diff --git a/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-messages/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-messages/+server.ts
new file mode 100644
index 000000000..8c8abd027
--- /dev/null
+++ b/infrastructure/control-panel/src/routes/api/evaults/[evaultId]/group-messages/+server.ts
@@ -0,0 +1,214 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public';
+import { registryService } from '$lib/services/registry';
+import {
+ evaultGraphqlPost,
+ fetchGroupManifestOrFallbackParsed,
+ fetchRegistryEvaultRows,
+ MESSAGE_ONTOLOGY_ID,
+ requestPlatformToken,
+ type RegistryEvaultRow
+} from '$lib/server/evault-graphql';
+import {
+ buildDashboardNameByKey,
+ resolveSenderRowFields,
+ userRegistryRowsForProbe
+} from '$lib/server/group-sender-resolve.server';
+import {
+ buildRegistryEnameLookup,
+ previewMessageBody,
+ senderBucketKey
+} from '$lib/server/group-message-buckets';
+
+const MESSAGES_PAGE_QUERY = `
+ query GroupMessagesList($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) {
+ metaEnvelopes(filter: $filter, first: $first, after: $after) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ node {
+ id
+ parsed
+ }
+ }
+ }
+ }
+`;
+
+const PAGE_SIZE = 100;
+const MAX_PAGES = 50;
+const MAX_BUCKET_PARAM_LEN = 512;
+
+type MessageListConnection = {
+ totalCount?: number;
+ pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
+ edges?: Array<{ node?: { id?: string; parsed?: Record } }>;
+};
+
+function readConnection(payload: Record | null): MessageListConnection | null {
+ if (!payload) {
+ return null;
+ }
+ const data = payload.data as Record | undefined;
+ const conn = data?.metaEnvelopes as MessageListConnection | undefined;
+ return conn ?? null;
+}
+
+export const GET: RequestHandler = async ({ params, url }) => {
+ const evaultId = params.evaultId;
+ const bucketParam = url.searchParams.get('bucket');
+
+ if (bucketParam == null || bucketParam === '') {
+ return json({ error: 'Missing required query parameter: bucket' }, { status: 400 });
+ }
+ if (bucketParam.length > MAX_BUCKET_PARAM_LEN) {
+ return json({ error: 'Invalid bucket parameter' }, { status: 400 });
+ }
+
+ const filterBucket = bucketParam;
+
+ try {
+ const allVaults = await registryService.getEVaults();
+ const registryEnameLookup = buildRegistryEnameLookup(allVaults);
+
+ const vault = allVaults.find((v) => v.evault === evaultId || v.ename === evaultId);
+
+ if (!vault) {
+ return json({ error: `eVault '${evaultId}' not found in registry.` }, { status: 404 });
+ }
+
+ const platform = PUBLIC_CONTROL_PANEL_URL || url.origin;
+ let token: string | undefined;
+ try {
+ token = await requestPlatformToken(platform);
+ } catch (tokenError) {
+ console.warn('Group messages: no platform token:', tokenError);
+ }
+
+ let registryRows: RegistryEvaultRow[] = [];
+ try {
+ registryRows = await fetchRegistryEvaultRows(token);
+ } catch (rowsErr) {
+ console.warn('Group messages: fetchRegistryEvaultRows failed:', rowsErr);
+ }
+ const dashboardNameByKey = buildDashboardNameByKey(registryRows);
+ const hintByBucket = new Map>();
+ const userRowsForProbe = userRegistryRowsForProbe(registryRows);
+ const probeRequestCache = new Map();
+ const { displayName: senderDisplayName, ename: senderEname } = await resolveSenderRowFields({
+ bucketKey: filterBucket,
+ allVaults,
+ registryEnameLookup,
+ token,
+ dashboardNameByKey,
+ hintByBucket,
+ userRowsForProbe,
+ probeRequestCache
+ });
+
+ const manifest = await fetchGroupManifestOrFallbackParsed(vault, token);
+ const manifestRec = manifest && typeof manifest === 'object' && !Array.isArray(manifest)
+ ? (manifest as Record)
+ : null;
+ const groupDisplayNameRaw = manifestRec?.name;
+ const groupDisplayName =
+ typeof groupDisplayNameRaw === 'string' && groupDisplayNameRaw.trim()
+ ? groupDisplayNameRaw.trim()
+ : vault.ename || evaultId || 'Group';
+
+ const messages: Array<{
+ id: string | null;
+ preview: string | undefined;
+ senderBucket: string;
+ }> = [];
+
+ let messagesScanned = 0;
+ let totalCount = 0;
+ let after: string | undefined;
+ let capped = false;
+
+ for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex++) {
+ const variables: Record = {
+ filter: { ontologyId: MESSAGE_ONTOLOGY_ID },
+ first: PAGE_SIZE
+ };
+ if (after) {
+ variables.after = after;
+ }
+
+ const payload = await evaultGraphqlPost({
+ uri: vault.uri,
+ ename: vault.ename,
+ query: MESSAGES_PAGE_QUERY,
+ variables,
+ token,
+ timeoutMs: 15000
+ });
+
+ const conn = readConnection(payload);
+ if (!conn) {
+ break;
+ }
+
+ if (pageIndex === 0 && typeof conn.totalCount === 'number') {
+ totalCount = conn.totalCount;
+ }
+
+ const edges = conn.edges ?? [];
+ for (const edge of edges) {
+ const node = edge.node;
+ const parsed = node?.parsed;
+ const p =
+ parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : undefined;
+ const bucket = senderBucketKey(p, registryEnameLookup);
+ messagesScanned += 1;
+ if (bucket === filterBucket) {
+ const graphqlId = typeof node?.id === 'string' ? node.id : null;
+ const parsedId = p && typeof p.id === 'string' ? p.id : null;
+ messages.push({
+ id: graphqlId ?? parsedId,
+ preview: previewMessageBody(p),
+ senderBucket: bucket
+ });
+ }
+ }
+
+ const hasNext = conn.pageInfo?.hasNextPage === true;
+ const endCursor = conn.pageInfo?.endCursor;
+ if (!hasNext || edges.length === 0) {
+ break;
+ }
+ if (pageIndex === MAX_PAGES - 1) {
+ capped = true;
+ break;
+ }
+ after = endCursor ?? undefined;
+ }
+
+ return json({
+ evault: {
+ ename: vault.ename,
+ uri: vault.uri,
+ evault: vault.evault
+ },
+ groupDisplayName,
+ senderDisplayName,
+ senderEname,
+ bucket: filterBucket,
+ messages,
+ matchedCount: messages.length,
+ messagesScanned,
+ totalCount,
+ capped
+ });
+ } catch (error) {
+ console.error('Error fetching group messages:', error);
+ return json({ error: 'Failed to fetch group messages' }, { status: 500 });
+ }
+};
diff --git a/infrastructure/control-panel/src/routes/api/evaults/groups/+server.ts b/infrastructure/control-panel/src/routes/api/evaults/groups/+server.ts
new file mode 100644
index 000000000..f2e4c9bce
--- /dev/null
+++ b/infrastructure/control-panel/src/routes/api/evaults/groups/+server.ts
@@ -0,0 +1,25 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from '@sveltejs/kit';
+import { PUBLIC_CONTROL_PANEL_URL } from '$env/static/public';
+import { fetchRegistryEvaultRows, requestPlatformToken } from '$lib/server/evault-graphql';
+import type { EVault } from '../+server';
+
+export const GET: RequestHandler = async ({ url }) => {
+ try {
+ const platform = PUBLIC_CONTROL_PANEL_URL || url.origin;
+ let token: string | undefined;
+ try {
+ token = await requestPlatformToken(platform);
+ } catch (tokenError) {
+ console.warn('Falling back to X-ENAME-only eVault queries:', tokenError);
+ }
+
+ const rows = await fetchRegistryEvaultRows(token);
+ const evaults: EVault[] = rows.filter((v) => v.type === 'group');
+
+ return json({ evaults });
+ } catch (error) {
+ console.error('Error fetching group eVaults:', error);
+ return json({ error: 'Failed to fetch group eVaults', evaults: [] }, { 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..8c8358834 100644
--- a/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte
+++ b/infrastructure/control-panel/src/routes/evaults/[evaultId]/+page.svelte
@@ -1,4 +1,5 @@
+
+
+ Group eVaults — Control Panel
+
+
+
+
Group eVaults
+
+ Registry entries with a readable group manifest. Open a row for members and message stats.
+
+
+
+
+ 0 ? `${filtered().length} group(s)` : ''}
+ />
+ {#if isLoading}
+
+ {:else if error}
+
+ {error}
+
+ Retry
+
+
+ {:else if groups.length === 0}
+ No group eVaults found.
+ {:else}
+
+
+
+ Showing {(currentPage - 1) * itemsPerPage + 1} –
+ {Math.min(currentPage * itemsPerPage, filtered().length)} of {filtered().length}
+
+
+ currentPage--}
+ >
+ Previous
+
+ Page {currentPage} / {totalPages}
+ = totalPages}
+ onclick={() => currentPage++}
+ >
+ Next
+
+
+
+ {/if}
+
+
+
+
+ Refresh
+
+
diff --git a/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte b/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte
new file mode 100644
index 000000000..c4af9b379
--- /dev/null
+++ b/infrastructure/control-panel/src/routes/groups/[evaultId]/+page.svelte
@@ -0,0 +1,183 @@
+
+
+
+ Group {insights?.manifest?.name ?? evaultId} — Control Panel
+
+
+
+
goto('/groups')}
+ >
+ ← Back to groups
+
+ {#if isLoading}
+
+ {:else if error}
+
+
Could not load this group
+
{error}
+
+ Retry
+
+
+ {:else if insights}
+ {@const m = insights.manifest}
+ {@const stats = insights.messageStats}
+ {@const withSender = stats.messagesWithSenderBucket}
+ {@const withoutSender = stats.messagesWithoutSender}
+
Real name
+
+ {asString(m.name) || insights.evault.ename || evaultId}
+
+
+ eName
+ {insights.evault.ename}
+
+ {#if asString(m.description)}
+
{asString(m.description)}
+ {/if}
+
+
+
+
Vault
+
+
+
eVault ID
+ {insights.evault.evault || '—'}
+
+
+
+
+
+
+
Messages
+
+ {stats.totalCount ?? stats.messagesScanned}
+ total in vault
+
+
+ Scanned {stats.messagesScanned} message envelope(s):{' '}
+ {withSender}
+ with a sender id or resolvable ename (bucketed in the table
+ below),
+ {withoutSender}
+ with no sender in the payload (system / null).
+
+ {#if stats.capped}
+
+ Counts may be incomplete: scanning stopped at the safety limit ({stats.messagesScanned}
+ messages).
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Messages by sender
+
+ Real name comes from each sender’s user vault (same fields as the dashboard). eName is the
+ W3ID when we can match the registry; otherwise the raw sender id from the message.
+
+
+
+ {#if stats.senderRows?.length}
+
+
+
+ Real name
+ eName
+ Count
+
+
+
+ {#each stats.senderRows as row}
+
+
+ {#if row.evaultPageId}
+ {row.displayName}
+ {:else}
+ {row.displayName}
+ {/if}
+
+ {row.ename}
+
+ {row.messageCount}
+
+
+ {/each}
+
+
+ {:else}
+
No message rows.
+ {/if}
+
+
+ {/if}
+
diff --git a/infrastructure/control-panel/src/routes/groups/[evaultId]/messages/+page.svelte b/infrastructure/control-panel/src/routes/groups/[evaultId]/messages/+page.svelte
new file mode 100644
index 000000000..bca547140
--- /dev/null
+++ b/infrastructure/control-panel/src/routes/groups/[evaultId]/messages/+page.svelte
@@ -0,0 +1,164 @@
+
+
+
+
+ {payload
+ ? `${payload.senderDisplayName} · ${payload.groupDisplayName} — Control Panel`
+ : 'Messages in group — Control Panel'}
+
+
+
+
+
goto(groupHref)}
+ >
+ ← Back to group
+
+
+ {#if isLoading}
+
+ {:else if error}
+
+
Could not load messages
+
{error}
+
+ Retry
+
+
+ {:else if payload}
+
Real name
+
{payload.senderDisplayName}
+
+ eName
+ {payload.senderEname}
+
+
+
+
Group
+
{payload.groupDisplayName}
+
+ eName
+ {payload.evault.ename}
+
+
+
+
+ Bucket {payload.bucket}
+ · matched {payload.matchedCount} of {payload.messagesScanned} scanned
+ {#if payload.totalCount}
+ · {payload.totalCount} total messages in vault (reported)
+ {/if}
+
+ {#if payload.capped}
+
+ Scan stopped at the safety limit; this list may be incomplete.
+
+ {/if}
+
+
+ {/if}
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b4e0b82f..4d64734ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -139,6 +139,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ d3-shape:
+ specifier: ^3.2.0
+ version: 3.2.0
flowbite:
specifier: ^3.1.2
version: 3.1.2(rollup@4.59.0)
@@ -151,6 +154,9 @@ importers:
jose:
specifier: ^6.2.0
version: 6.2.1
+ layercake:
+ specifier: ^10.0.2
+ version: 10.0.2(svelte@5.53.11)(typescript@5.8.2)
lowdb:
specifier: ^7.0.1
version: 7.0.1
@@ -194,6 +200,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.1(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ '@types/d3-shape':
+ specifier: ^3.1.8
+ version: 3.1.8
'@types/node':
specifier: ^22
version: 22.19.15
@@ -3123,7 +3132,7 @@ importers:
version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
draft-js:
specifier: ^0.11.7
- version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
lucide-react:
specifier: ^0.561.0
version: 0.561.0(react@18.3.1)
@@ -3144,7 +3153,7 @@ importers:
version: 18.3.1(react@18.3.1)
react-draft-wysiwyg:
specifier: ^1.15.0
- version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-hook-form:
specifier: ^7.55.0
version: 7.71.2(react@18.3.1)
@@ -15039,6 +15048,13 @@ packages:
launch-editor@2.13.1:
resolution: {integrity: sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==}
+ layercake@10.0.2:
+ resolution: {integrity: sha512-cYtPVA+AL/HbAzsH5xDL9/pbuNuhboGUI9GBtrC1Akjsx4V74rvBmUHZW8lO2ufCDMu+J2CtBRKgO8rkONsYBw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+ peerDependencies:
+ svelte: '>=5'
+ typescript: ^5.0.2
+
layout-base@1.0.2:
resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
@@ -31657,9 +31673,9 @@ snapshots:
dotenv@17.3.1: {}
- draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
- fbjs: 2.0.0(encoding@0.1.13)
+ fbjs: 2.0.0
immutable: 3.7.6
object-assign: 4.1.1
react: 18.3.1
@@ -31667,9 +31683,9 @@ snapshots:
transitivePeerDependencies:
- encoding
- draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5):
+ draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5):
dependencies:
- draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
immutable: 5.1.5
drizzle-kit@0.31.9:
@@ -32963,7 +32979,7 @@ snapshots:
fbjs-css-vars@1.0.2: {}
- fbjs@2.0.0(encoding@0.1.13):
+ fbjs@2.0.0:
dependencies:
core-js: 3.48.0
cross-fetch: 3.2.0(encoding@0.1.13)
@@ -33844,9 +33860,9 @@ snapshots:
html-tags@3.3.1: {}
- html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5):
+ html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5):
dependencies:
- draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
immutable: 5.1.5
html-url-attributes@3.0.1: {}
@@ -35378,6 +35394,15 @@ snapshots:
picocolors: 1.1.1
shell-quote: 1.8.3
+ layercake@10.0.2(svelte@5.53.11)(typescript@5.8.2):
+ dependencies:
+ d3-array: 3.2.4
+ d3-color: 3.1.0
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ svelte: 5.53.11
+ typescript: 5.8.2
+
layout-base@1.0.2: {}
layout-base@2.0.1: {}
@@ -38147,12 +38172,12 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
- react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
classnames: 2.5.1
- draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)
- html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)
+ draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)
+ html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)
immutable: 5.1.5
linkify-it: 2.2.0
prop-types: 15.8.1