The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 132dee380109Commit detailsView latest

apps/dashboard/src/main.tsx 1159 lines · 37.1 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import './styles.css';
4
5
declare const __AUTH0_DOMAIN__: string;
6
declare const __AUTH0_CLIENT_ID__: string;
7
declare const __AUTH0_AUDIENCE__: string;
8
9
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
10
type AuthSession = { user: Auth0User };
11
type AuthTransaction = { state: string; nonce: string; verifier: string };
12
type Theme = 'dark' | 'light';
13
14
const authTransactionKey = 'openclasp.auth0.transaction';
15
const themeKey = 'openclasp.theme.v1';
16
17
type DashboardData = {
18
  agents: Record<string, any>[];
19
  projects: Record<string, any>[];
20
  installations: Record<string, any>[];
21
  setupRequests: Record<string, any>[];
22
  publications: Record<string, any>[];
23
  interactions: Record<string, any>[];
24
  federatedInteractions: Record<string, any>[];
25
  events: Record<string, any>[];
26
  conflicts: Record<string, any>[];
27
  receipts: Record<string, any>[];
28
  profiles: Record<string, any>[];
29
};
30
31
type Settings = {
32
  displayName: string;
33
  contributionEnabled: boolean;
34
  retentionDays: number;
35
  evidenceSharing: 'never' | 'ask' | 'contract_only';
36
  rawConversationsStored: false;
37
};
38
39
const emptyData: DashboardData = {
40
  agents: [],
41
  projects: [],
42
  installations: [],
43
  setupRequests: [],
44
  publications: [],
45
  interactions: [],
46
  federatedInteractions: [],
47
  events: [],
48
  conflicts: [],
49
  receipts: [],
50
  profiles: [],
51
};
52
const defaultSettings: Settings = {
53
  displayName: '',
54
  contributionEnabled: false,
55
  retentionDays: 30,
56
  evidenceSharing: 'ask',
57
  rawConversationsStored: false,
58
};
59
const pages = ['dashboard', 'history', 'agents', 'insights', 'connect', 'settings'] as const;
60
type Page = (typeof pages)[number];
61
62
function base64Url(bytes: Uint8Array) {
63
  let binary = '';
64
  for (const byte of bytes) binary += String.fromCharCode(byte);
65
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
66
}
67
68
function randomValue(size = 32) {
69
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
70
}
71
72
async function beginAuth(provider: 'google' | 'github') {
73
  const verifier = randomValue(48);
74
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
75
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
76
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
77
  const parameters = new URLSearchParams({
78
    client_id: __AUTH0_CLIENT_ID__,
79
    redirect_uri: `${location.origin}/sso-callback`,
80
    response_type: 'code',
81
    scope: 'openid profile email',
82
    audience: __AUTH0_AUDIENCE__,
83
    state: transaction.state,
84
    nonce: transaction.nonce,
85
    code_challenge: base64Url(new Uint8Array(digest)),
86
    code_challenge_method: 'S256',
87
    connection: provider === 'google' ? 'google-oauth2' : 'github',
88
  });
89
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
90
}
91
92
async function signOut() {
93
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
94
  const parameters = new URLSearchParams({
95
    client_id: __AUTH0_CLIENT_ID__,
96
    returnTo: `${location.origin}/login`,
97
  });
98
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
99
}
100
101
async function api(path: string, init?: RequestInit) {
102
  const headers = new Headers(init?.headers);
103
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
104
  return fetch(path, {
105
    ...init,
106
    credentials: 'same-origin',
107
    headers,
108
  }).then(async (response) => {
109
    if (!response.ok)
110
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
111
    return response.json();
112
  });
113
}
114
115
function route(): Page {
116
  const value = location.pathname.slice(1) || 'dashboard';
117
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
118
}
119
120
function initialTheme(): Theme {
121
  const saved = localStorage.getItem(themeKey);
122
  if (saved === 'dark' || saved === 'light') return saved;
123
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
124
}
125
126
function App() {
127
  const [session, setSession] = useState<AuthSession | null>();
128
  const [theme, setTheme] = useState<Theme>(initialTheme);
129
  const [page, setPage] = useState<Page>(route());
130
  const [data, setData] = useState<DashboardData>(emptyData);
131
  const [settings, setSettings] = useState<Settings>(defaultSettings);
132
  const [loading, setLoading] = useState(true);
133
  const [error, setError] = useState('');
134
  const refreshDashboard = useCallback(async () => {
135
    setData((await api('/v0.1/dashboard')) as DashboardData);
136
  }, []);
137
138
  useEffect(() => {
139
    fetch('/api/session', { credentials: 'same-origin' })
140
      .then(async (response) => {
141
        if (!response.ok) return null;
142
        return (await response.json()) as AuthSession;
143
      })
144
      .then(setSession)
145
      .catch(() => setSession(null));
146
  }, []);
147
148
  useEffect(() => {
149
    document.documentElement.dataset.theme = theme;
150
    localStorage.setItem(themeKey, theme);
151
  }, [theme]);
152
153
  useEffect(() => {
154
    const onPopState = () => setPage(route());
155
    addEventListener('popstate', onPopState);
156
    return () => removeEventListener('popstate', onPopState);
157
  }, []);
158
159
  useEffect(() => {
160
    if (session === undefined) return;
161
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
162
    if (!session) return;
163
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
164
    setPage(route());
165
    Promise.all([api('/v0.1/dashboard'), api('/v0.1/settings')])
166
      .then(([dashboard, accountSettings]) => {
167
        setData(dashboard as DashboardData);
168
        setSettings(accountSettings as Settings);
169
      })
170
      .catch((reason: unknown) =>
171
        setError(reason instanceof Error ? reason.message : 'Load failed'),
172
      )
173
      .finally(() => setLoading(false));
174
  }, [session]);
175
176
  const navigate = (next: Page) => {
177
    history.pushState({}, '', `/${next}`);
178
    setPage(next);
179
  };
180
181
  if (session === undefined) return <Loading />;
182
  if (!session) return <Login />;
183
184
  return (
185
    <div className="appShell">
186
      <aside>
187
        <div className="brand">
188
          <div className="mark">OC</div>
189
          <div>
190
            <strong>OpenClasp</strong>
191
            <small>ASSURANCE NETWORK</small>
192
          </div>
193
        </div>
194
        <nav>
195
          <Nav page="dashboard" active={page} onClick={navigate} label="Overview" glyph="⌂" />
196
          <Nav page="history" active={page} onClick={navigate} label="History" glyph="≡" />
197
          <Nav page="agents" active={page} onClick={navigate} label="Agents" glyph="◇" />
198
          <Nav page="insights" active={page} onClick={navigate} label="Insights" glyph="⌁" />
199
          <Nav page="connect" active={page} onClick={navigate} label="Connect" glyph="+" />
200
          <Nav page="settings" active={page} onClick={navigate} label="Settings" glyph="⚙" />
201
        </nav>
202
        <button
203
          className="themeSwitch"
204
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
205
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
206
        >
207
          <span>{theme === 'dark' ? '☼' : '◐'}</span>
208
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
209
        </button>
210
        <div className="privacyStamp">
211
          <span className="liveDot" />
212
          <div>
213
            <strong>Structured-only</strong>
214
            <small>Raw conversations stay local</small>
215
          </div>
216
        </div>
217
        <button className="account" onClick={() => void signOut()}>
218
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
219
          <div>
220
            <strong>{session.user.name || 'OpenClasp user'}</strong>
221
            <small>{session.user.email || 'Sign out'}</small>
222
          </div>
223
          <b>↗</b>
224
        </button>
225
      </aside>
226
      <main>
227
        {error && <div className="errorBar">{error}</div>}
228
        {loading ? (
229
          <Loading compact />
230
        ) : (
231
          <PageContent
232
            page={page}
233
            data={data}
234
            settings={settings}
235
            setSettings={setSettings}
236
            navigate={navigate}
237
            refreshDashboard={refreshDashboard}
238
          />
239
        )}
240
      </main>
241
    </div>
242
  );
243
}
244
245
function Login() {
246
  const [error, setError] = useState('');
247
  const continueWith = async (provider: 'google' | 'github') => {
248
    setError('');
249
    try {
250
      await beginAuth(provider);
251
    } catch {
252
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
253
    }
254
  };
255
  return (
256
    <div className="loginPage">
257
      <section className="loginPitch">
258
        <div className="brand">
259
          <div className="mark">OC</div>
260
          <strong>OpenClasp</strong>
261
        </div>
262
        <div>
263
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
264
          <h1>Know who your agent is dealing with.</h1>
265
          <p>
266
            Verified identities, signed outcomes, private warnings, and contextual
267
            reliability—without collecting raw conversations.
268
          </p>
269
        </div>
270
        <div className="loginProof">
271
          <span>01</span> User-owned history<span>02</span> No universal trust score<span>03</span>{' '}
272
          Explicit network consent
273
        </div>
274
      </section>
275
      <section className="loginCard">
276
        <div>
277
          <p className="eyebrow">SECURE ACCESS</p>
278
          <h2>Sign in to OpenClasp</h2>
279
          <p>
280
            Manage connected agents, review signed history, and control what contributes to the
281
            network.
282
          </p>
283
        </div>
284
        <div className="socialButtons">
285
          <button onClick={() => void continueWith('google')}>
286
            <span className="google">G</span> Continue with Google
287
          </button>
288
          <button onClick={() => void continueWith('github')}>
289
            <span>◉</span> Continue with GitHub
290
          </button>
291
        </div>
292
        {error && <div className="loginError">{error}</div>}
293
        <small>
294
          Google and GitHub handle authentication. OpenClasp never receives your password.
295
        </small>
296
      </section>
297
    </div>
298
  );
299
}
300
301
let callbackStarted = false;
302
function AuthCallback() {
303
  const [error, setError] = useState('');
304
  useEffect(() => {
305
    if (callbackStarted) return;
306
    callbackStarted = true;
307
    const finish = async () => {
308
      const query = new URLSearchParams(location.search);
309
      const oauthError = query.get('error_description') ?? query.get('error');
310
      if (oauthError) throw new Error(oauthError);
311
      const code = query.get('code');
312
      const returnedState = query.get('state');
313
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
314
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
315
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
316
      sessionStorage.removeItem(authTransactionKey);
317
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
318
      const response = await fetch('/api/session', {
319
        method: 'POST',
320
        credentials: 'same-origin',
321
        headers: { 'content-type': 'application/json' },
322
        body: JSON.stringify({
323
          code,
324
          codeVerifier: transaction.verifier,
325
        }),
326
      });
327
      if (!response.ok) throw new Error('Auth0 token exchange failed');
328
      location.replace('/dashboard');
329
    };
330
    void finish().catch((reason: unknown) =>
331
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
332
    );
333
  }, []);
334
  if (error)
335
    return (
336
      <div className="loading">
337
        <span className="mark">OC</span>
338
        <p>{error}</p>
339
        <a href="/login">Return to login</a>
340
      </div>
341
    );
342
  return <Loading />;
343
}
344
345
function PageContent({
346
  page,
347
  data,
348
  settings,
349
  setSettings,
350
  navigate,
351
  refreshDashboard,
352
}: {
353
  page: Page;
354
  data: DashboardData;
355
  settings: Settings;
356
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
357
  navigate: (page: Page) => void;
358
  refreshDashboard: () => Promise<void>;
359
}) {
360
  if (page === 'history') return <History data={data} />;
361
  if (page === 'agents')
362
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} />;
363
  if (page === 'insights') return <Insights data={data} />;
364
  if (page === 'connect') return <Connect data={data} refreshDashboard={refreshDashboard} />;
365
  if (page === 'settings') return <SettingsPage settings={settings} setSettings={setSettings} />;
366
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} />;
367
}
368
369
function Overview({
370
  data,
371
  navigate,
372
  refreshDashboard,
373
}: {
374
  data: DashboardData;
375
  navigate: (page: Page) => void;
376
  refreshDashboard: () => Promise<void>;
377
}) {
378
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
379
  const warnings = data.events.filter((item) =>
380
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
381
  ).length;
382
  return (
383
    <>
384
      <PageHead
385
        eyebrow="NETWORK OVERVIEW"
386
        title="Assurance, at a glance."
387
        action="Connect agent"
388
        onAction={() => navigate('connect')}
389
      />
390
      <section className="metrics">
391
        <Metric label="Connected agents" value={data.agents.length} note="bound identities" />
392
        <Metric
393
          label="Interactions"
394
          value={data.interactions.length + data.federatedInteractions.length}
395
          note="local and shared"
396
        />
397
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
398
        <Metric label="Open warnings" value={warnings} note="needs attention" warn={warnings > 0} />
399
      </section>
400
      <Invitations data={data} refreshDashboard={refreshDashboard} />
401
      <section className="contentGrid">
402
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
403
          <Timeline events={data.events.slice(-6).reverse()} />
404
          <TextButton onClick={() => navigate('history')}>View complete history →</TextButton>
405
        </Panel>
406
        <Panel
407
          title="Reliability context"
408
          subtitle="Task-specific profiles, never a universal score"
409
        >
410
          {data.profiles.length ? (
411
            data.profiles
412
              .slice(0, 4)
413
              .map((profile) => (
414
                <Profile key={`${profile.agentId}-${profile.taskCategory}`} profile={profile} />
415
              ))
416
          ) : (
417
            <Empty
418
              title="No reliability profile yet"
419
              text="Profiles appear after signed receipts and eligible bilateral feedback."
420
            />
421
          )}
422
        </Panel>
423
      </section>
424
    </>
425
  );
426
}
427
428
function History({ data }: { data: DashboardData }) {
429
  const records = useMemo(
430
    () =>
431
      [
432
        ...data.interactions.map((value) => ({ ...value, _kind: 'interaction' })),
433
        ...data.federatedInteractions.map((value) => ({
434
          ...value,
435
          _kind: 'federated interaction',
436
        })),
437
        ...data.events.map((value) => ({ ...value, _kind: 'event' })),
438
        ...data.receipts.map((value) => ({ ...value, _kind: 'receipt' })),
439
        ...data.conflicts.map((value) => ({ ...value, _kind: 'dispute' })),
440
      ].sort((a, b) => Date.parse(timestamp(b)) - Date.parse(timestamp(a))),
441
    [data],
442
  );
443
  return (
444
    <>
445
      <PageHead eyebrow="AUDIT HISTORY" title="What happened, and what proves it." />
446
      <Panel
447
        title="Account history"
448
        subtitle="Only structured metadata, signatures, hashes, and permitted evidence are hosted"
449
      >
450
        {records.length ? (
451
          records.map((item, index) => (
452
            <HistoryRow
453
              key={String(
454
                (item as Record<string, any>).eventId ??
455
                  (item as Record<string, any>).receiptId ??
456
                  (item as Record<string, any>).interactionId ??
457
                  index,
458
              )}
459
              item={item}
460
            />
461
          ))
462
        ) : (
463
          <Empty
464
            title="No interactions recorded"
465
            text="Connect an agent and start an assured interaction. Raw message bodies will not appear here."
466
          />
467
        )}
468
      </Panel>
469
    </>
470
  );
471
}
472
473
function Agents({
474
  data,
475
  navigate,
476
  refreshDashboard,
477
}: {
478
  data: DashboardData;
479
  navigate: (page: Page) => void;
480
  refreshDashboard: () => Promise<void>;
481
}) {
482
  const [working, setWorking] = useState('');
483
  const [error, setError] = useState('');
484
  const setPublication = async (agentId: string, published: boolean) => {
485
    setWorking(agentId);
486
    setError('');
487
    try {
488
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/publication`, {
489
        method: 'POST',
490
        body: JSON.stringify({ published }),
491
      });
492
      await refreshDashboard();
493
    } catch (reason) {
494
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
495
    } finally {
496
      setWorking('');
497
    }
498
  };
499
  return (
500
    <>
501
      <PageHead
502
        eyebrow="IDENTITY REGISTRY"
503
        title="Your connected agents."
504
        action="Connect agent"
505
        onAction={() => navigate('connect')}
506
      />
507
      <div className="notice">
508
        <strong>Private by default.</strong> Publishing shares only this agent's name, framework,
509
        capabilities, limitations, and assurance method. It never shares your identity, project,
510
        scores, or conversations.
511
      </div>
512
      {error && <div className="errorBar">{error}</div>}
513
      <section className="agentGrid">
514
        {data.agents.length ? (
515
          data.agents.map((agent) => (
516
            <AgentCard
517
              key={agent.agentId}
518
              agent={agent}
519
              projectName={
520
                data.projects.find((project) => project.projectId === agent.projectId)?.name
521
              }
522
              published={data.publications.some(
523
                (publication) => publication.agentId === agent.agentId && publication.published,
524
              )}
525
              working={working === agent.agentId}
526
              onPublication={(published) => setPublication(agent.agentId, published)}
527
            />
528
          ))
529
        ) : (
530
          <Empty
531
            title="No agents connected"
532
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
533
            action="Open connection guide"
534
            onAction={() => navigate('connect')}
535
          />
536
        )}
537
      </section>
538
    </>
539
  );
540
}
541
542
function Invitations({
543
  data,
544
  refreshDashboard,
545
}: {
546
  data: DashboardData;
547
  refreshDashboard: () => Promise<void>;
548
}) {
549
  const [working, setWorking] = useState('');
550
  const [error, setError] = useState('');
551
  const ownedAgentIds = useMemo(
552
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
553
    [data.agents],
554
  );
555
  const incoming = data.federatedInteractions.filter(
556
    (interaction) =>
557
      interaction.status === 'pending' && ownedAgentIds.has(String(interaction.responderAgentId)),
558
  );
559
  const respond = async (interactionId: string, agentId: string, decision: 'accept' | 'reject') => {
560
    setWorking(interactionId);
561
    setError('');
562
    try {
563
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/respond`, {
564
        method: 'POST',
565
        body: JSON.stringify({ agentId, decision }),
566
      });
567
      await refreshDashboard();
568
    } catch (reason) {
569
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
570
    } finally {
571
      setWorking('');
572
    }
573
  };
574
  if (!incoming.length && !data.federatedInteractions.length) return null;
575
  return (
576
    <Panel
577
      title={incoming.length ? `Agent invitations (${incoming.length})` : 'Shared interactions'}
578
      subtitle="Both accounts see the same immutable contract and acceptance state"
579
    >
580
      {error ? <div className="errorBar">{error}</div> : null}
581
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
582
        <article className="setupRequest" key={interaction.interactionId}>
583
          <div>
584
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
585
            <small>
586
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
587
            </small>
588
            <small>Contract: {String(interaction.termsHash).slice(0, 16)}…</small>
589
          </div>
590
          {interaction.status === 'pending' &&
591
          ownedAgentIds.has(String(interaction.responderAgentId)) ? (
592
            <div className="decisionButtons">
593
              <button
594
                className="secondary"
595
                disabled={working === interaction.interactionId}
596
                onClick={() =>
597
                  void respond(interaction.interactionId, interaction.responderAgentId, 'reject')
598
                }
599
              >
600
                Reject
601
              </button>
602
              <button
603
                className="primary"
604
                disabled={working === interaction.interactionId}
605
                onClick={() =>
606
                  void respond(interaction.interactionId, interaction.responderAgentId, 'accept')
607
                }
608
              >
609
                {working === interaction.interactionId ? 'Working…' : 'Accept contract'}
610
              </button>
611
            </div>
612
          ) : null}
613
        </article>
614
      ))}
615
    </Panel>
616
  );
617
}
618
619
function Insights({ data }: { data: DashboardData }) {
620
  return (
621
    <>
622
      <PageHead eyebrow="BEHAVIOURAL CONTEXT" title="Evidence, not reputation theatre." />
623
      <div className="notice">
624
        <strong>Context matters.</strong> OpenClasp profiles an agent by task category and version.
625
        It does not produce a single universal trust score.
626
      </div>
627
      <section className="agentGrid">
628
        {data.profiles.length ? (
629
          data.profiles.map((profile) => (
630
            <Panel
631
              key={`${profile.agentId}-${profile.taskCategory}`}
632
              title={profile.agentId}
633
              subtitle={`${profile.taskCategory} · v${profile.agentVersion}`}
634
            >
635
              <Profile profile={profile} expanded />
636
            </Panel>
637
          ))
638
        ) : (
639
          <Empty
640
            title="Not enough verified evidence"
641
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
642
          />
643
        )}
644
      </section>
645
    </>
646
  );
647
}
648
649
function Connect({
650
  data,
651
  refreshDashboard,
652
}: {
653
  data: DashboardData;
654
  refreshDashboard: () => Promise<void>;
655
}) {
656
  const endpoint = 'https://openclasp.vercel.app/mcp';
657
  const [copied, setCopied] = useState(false);
658
  const [working, setWorking] = useState('');
659
  const [decisionError, setDecisionError] = useState('');
660
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
661
  useEffect(() => {
662
    const poll = () => void refreshDashboard().catch(() => undefined);
663
    poll();
664
    const timer = window.setInterval(poll, 5000);
665
    return () => window.clearInterval(timer);
666
  }, [refreshDashboard]);
667
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
668
    setWorking(requestId);
669
    setDecisionError('');
670
    try {
671
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
672
        method: 'POST',
673
      });
674
      await refreshDashboard();
675
    } catch (reason) {
676
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
677
    } finally {
678
      setWorking('');
679
    }
680
  };
681
  return (
682
    <>
683
      <PageHead eyebrow="AGENT CONNECTION" title="Add OpenClasp to an agent." />
684
      {pending.length > 0 && (
685
        <section className="setupRequests">
686
          <div>
687
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
688
            <h2>Approve agent setup</h2>
689
            <p>
690
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
691
            </p>
692
          </div>
693
          {pending.map((request) => (
694
            <article className="setupRequest" key={request.requestId}>
695
              <div>
696
                <strong>{request.agentName ?? 'Switch installation'}</strong>
697
                <small>
698
                  {request.projectName ?? request.existingAgentId} · {request.framework}
699
                </small>
700
                {!!request.capabilities?.length && (
701
                  <div className="tags">
702
                    {request.capabilities.slice(0, 5).map((capability: string) => (
703
                      <span key={capability}>{capability}</span>
704
                    ))}
705
                  </div>
706
                )}
707
                {!!request.limitations?.length && (
708
                  <small>Limitations: {request.limitations.join(' · ')}</small>
709
                )}
710
              </div>
711
              <div className="decisionButtons">
712
                <button
713
                  className="secondary"
714
                  disabled={working === request.requestId}
715
                  onClick={() => void decide(request.requestId, 'reject')}
716
                >
717
                  Reject
718
                </button>
719
                <button
720
                  className="primary"
721
                  disabled={working === request.requestId}
722
                  onClick={() => void decide(request.requestId, 'approve')}
723
                >
724
                  {working === request.requestId ? 'Working…' : 'Confirm agent'}
725
                </button>
726
              </div>
727
            </article>
728
          ))}
729
          {decisionError && <div className="loginError">{decisionError}</div>}
730
        </section>
731
      )}
732
      <section className="connectLayout">
733
        <Panel title="Remote MCP endpoint" subtitle="For OAuth-capable MCP clients">
734
          <div className="endpoint">
735
            <code>{endpoint}</code>
736
            <button
737
              onClick={() => {
738
                void navigator.clipboard.writeText(endpoint);
739
                setCopied(true);
740
              }}
741
            >
742
              {copied ? 'Copied' : 'Copy'}
743
            </button>
744
          </div>
745
          <ol>
746
            <li>Add the URL as a remote MCP server in your agent or framework.</li>
747
            <li>The client discovers OAuth and opens the Auth0 login page.</li>
748
            <li>Sign in with this account and approve access.</li>
749
            <li>
750
              Ask the agent to call <code>openclasp_setup</code>.
751
            </li>
752
            <li>
753
              Confirm the proposed identity on this page. The installation stays bound afterward.
754
            </li>
755
          </ol>
756
        </Panel>
757
        <Panel
758
          title="What the agent receives"
759
          subtitle="Assurance tools, not a replacement transport"
760
        >
761
          <ul className="checkList">
762
            <li>Identity and delegation verification</li>
763
            <li>Counterparty-specific reliability clues</li>
764
            <li>Signed contracts, events, feedback, and receipts</li>
765
            <li>Policy challenges and consented mediation</li>
766
            <li>No hidden rewriting or raw-message upload</li>
767
          </ul>
768
        </Panel>
769
      </section>
770
    </>
771
  );
772
}
773
774
function SettingsPage({
775
  settings,
776
  setSettings,
777
}: {
778
  settings: Settings;
779
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
780
}) {
781
  const [saving, setSaving] = useState(false);
782
  const [saved, setSaved] = useState(false);
783
  const save = useCallback(async () => {
784
    setSaving(true);
785
    setSaved(false);
786
    const updated = (await api('/v0.1/settings', {
787
      method: 'PUT',
788
      body: JSON.stringify(settings),
789
    })) as Settings;
790
    setSettings(updated);
791
    setSaving(false);
792
    setSaved(true);
793
  }, [settings, setSettings]);
794
  return (
795
    <>
796
      <PageHead eyebrow="ACCOUNT CONTROLS" title="Privacy and network settings." />
797
      <section className="settingsCard">
798
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
799
          <input
800
            value={settings.displayName}
801
            onChange={(event) =>
802
              setSettings((value) => ({ ...value, displayName: event.target.value }))
803
            }
804
            placeholder="Team or operator name"
805
          />
806
        </Setting>
807
        <Setting
808
          label="Network contribution"
809
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
810
        >
811
          <Toggle
812
            checked={settings.contributionEnabled}
813
            onChange={(checked) =>
814
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
815
            }
816
          />
817
        </Setting>
818
        <Setting
819
          label="Evidence sharing"
820
          description="Controls when permitted evidence references may be shared with interaction participants."
821
        >
822
          <select
823
            value={settings.evidenceSharing}
824
            onChange={(event) =>
825
              setSettings((value) => ({
826
                ...value,
827
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
828
              }))
829
            }
830
          >
831
            <option value="never">Never</option>
832
            <option value="ask">Ask every time</option>
833
            <option value="contract_only">When contract permits</option>
834
          </select>
835
        </Setting>
836
        <Setting
837
          label="Structured record retention"
838
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
839
        >
840
          <select
841
            value={settings.retentionDays}
842
            onChange={(event) =>
843
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
844
            }
845
          >
846
            <option value={7}>7 days</option>
847
            <option value={30}>30 days</option>
848
            <option value={90}>90 days</option>
849
            <option value={365}>1 year</option>
850
            <option value={0}>No automatic deletion</option>
851
          </select>
852
        </Setting>
853
        <Setting
854
          label="Raw conversations"
855
          description="Message bodies remain local and user-owned. This cannot be enabled on the hosted service."
856
        >
857
          <span className="locked">ALWAYS OFF</span>
858
        </Setting>
859
        <div className="saveRow">
860
          <button className="primary" onClick={() => void save()} disabled={saving}>
861
            {saving ? 'Saving…' : 'Save settings'}
862
          </button>
863
          {saved && <span>Saved</span>}
864
        </div>
865
      </section>
866
    </>
867
  );
868
}
869
870
function PageHead({
871
  eyebrow,
872
  title,
873
  action,
874
  onAction,
875
}: {
876
  eyebrow: string;
877
  title: string;
878
  action?: string;
879
  onAction?: () => void;
880
}) {
881
  return (
882
    <header className="pageHead">
883
      <div>
884
        <p className="eyebrow">{eyebrow}</p>
885
        <h1>{title}</h1>
886
      </div>
887
      {action && (
888
        <button className="primary" onClick={onAction}>
889
          {action}
890
        </button>
891
      )}
892
    </header>
893
  );
894
}
895
function Nav({
896
  page,
897
  active,
898
  onClick,
899
  label,
900
  glyph,
901
}: {
902
  page: Page;
903
  active: Page;
904
  onClick: (page: Page) => void;
905
  label: string;
906
  glyph: string;
907
}) {
908
  return (
909
    <button className={active === page ? 'active' : ''} onClick={() => onClick(page)}>
910
      <span>{glyph}</span>
911
      {label}
912
    </button>
913
  );
914
}
915
function Metric({
916
  label,
917
  value,
918
  note,
919
  warn,
920
}: {
921
  label: string;
922
  value: number;
923
  note: string;
924
  warn?: boolean;
925
}) {
926
  return (
927
    <article className={warn ? 'metric warn' : 'metric'}>
928
      <span>{label}</span>
929
      <strong>{String(value).padStart(2, '0')}</strong>
930
      <small>{note}</small>
931
    </article>
932
  );
933
}
934
function Panel({
935
  title,
936
  subtitle,
937
  children,
938
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
939
  return (
940
    <section className="panel">
941
      <div className="panelHead">
942
        <div>
943
          <h2>{title}</h2>
944
          <p>{subtitle}</p>
945
        </div>
946
      </div>
947
      {children}
948
    </section>
949
  );
950
}
951
function Timeline({ events }: { events: Record<string, any>[] }) {
952
  return events.length ? (
953
    <div className="timeline">
954
      {events.map((event) => (
955
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
956
      ))}
957
    </div>
958
  ) : (
959
    <Empty
960
      title="Nothing recorded yet"
961
      text="Activity appears after your connected agents submit signed structured events."
962
    />
963
  );
964
}
965
function HistoryRow({ item }: { item: Record<string, any> }) {
966
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
967
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
968
  return (
969
    <article className="historyRow">
970
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
971
      <div>
972
        <strong>{kind}</strong>
973
        <small>
974
          {id} · {new Date(timestamp(item)).toLocaleString()}
975
        </small>
976
      </div>
977
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
978
    </article>
979
  );
980
}
981
function AgentCard({
982
  agent,
983
  projectName,
984
  published,
985
  working,
986
  onPublication,
987
}: {
988
  agent: Record<string, any>;
989
  projectName?: string;
990
  published: boolean;
991
  working: boolean;
992
  onPublication: (published: boolean) => void;
993
}) {
994
  const identityLabel = agent.revoked
995
    ? 'REVOKED'
996
    : agent.identityMode === 'oauth_installation'
997
      ? 'AUTHENTICATED'
998
      : 'VERIFIED';
999
  return (
1000
    <article className="agentCard">
1001
      <div className="agentTop">
1002
        <span className="agentGlyph">◇</span>
1003
        <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
1004
      </div>
1005
      <h2>{agent.name ?? agent.agentId}</h2>
1006
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
1007
      <div className="tags">
1008
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
1009
          <span key={capability}>{capability}</span>
1010
        ))}
1011
      </div>
1012
      <small>
1013
        {agent.framework ? `${agent.framework} · ` : ''}
1014
        {agent.identityMode === 'oauth_installation' ? 'OAuth-bound · ' : 'Ed25519 · '}Created{' '}
1015
        {new Date(agent.createdAt).toLocaleDateString()}
1016
      </small>
1017
      {published ? (
1018
        <a
1019
          href={`/agents/${encodeURIComponent(agent.agentId)}/card.json`}
1020
          target="_blank"
1021
          rel="noreferrer"
1022
        >
1023
          Public Agent Card ↗
1024
        </a>
1025
      ) : null}
1026
      {!agent.a2aEndpoint ? (
1027
        <small>Add an A2A endpoint before other agents can connect.</small>
1028
      ) : null}
1029
      <button
1030
        className="secondary"
1031
        disabled={working || agent.status === 'revoked'}
1032
        onClick={() => onPublication(!published)}
1033
      >
1034
        {working ? 'Updating…' : published ? 'Remove from directory' : 'Publish to directory'}
1035
      </button>
1036
    </article>
1037
  );
1038
}
1039
function Profile({ profile, expanded }: { profile: Record<string, any>; expanded?: boolean }) {
1040
  const dimensions = [
1041
    ['Completion', profile.completion],
1042
    ['Scope', profile.scope],
1043
    ['Evidence', profile.evidence],
1044
    ...(expanded
1045
      ? [
1046
          ['Communication', profile.communication],
1047
          ['Deadline', profile.deadline],
1048
        ]
1049
      : []),
1050
  ] as [string, number][];
1051
  return (
1052
    <article className="profile">
1053
      <div>
1054
        <strong>{profile.agentId}</strong>
1055
        <small>
1056
          {profile.taskCategory} · {profile.sampleSize} outcome(s)
1057
        </small>
1058
      </div>
1059
      {dimensions.map(([label, value]) => (
1060
        <Meter key={label} label={label} value={value ?? 0} />
1061
      ))}
1062
    </article>
1063
  );
1064
}
1065
function Meter({ label, value }: { label: string; value: number }) {
1066
  return (
1067
    <div className="meter">
1068
      <span>{label}</span>
1069
      <i>
1070
        <b style={{ width: `${Math.round(value * 100)}%` }} />
1071
      </i>
1072
      <strong>{Math.round(value * 100)}%</strong>
1073
    </div>
1074
  );
1075
}
1076
function Empty({
1077
  title,
1078
  text,
1079
  action,
1080
  onAction,
1081
}: {
1082
  title: string;
1083
  text: string;
1084
  action?: string;
1085
  onAction?: () => void;
1086
}) {
1087
  return (
1088
    <div className="empty">
1089
      <span>◇</span>
1090
      <strong>{title}</strong>
1091
      <p>{text}</p>
1092
      {action && (
1093
        <button className="secondary" onClick={onAction}>
1094
          {action}
1095
        </button>
1096
      )}
1097
    </div>
1098
  );
1099
}
1100
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
1101
  return (
1102
    <button className="textButton" onClick={onClick}>
1103
      {children}
1104
    </button>
1105
  );
1106
}
1107
function Setting({
1108
  label,
1109
  description,
1110
  children,
1111
}: React.PropsWithChildren<{ label: string; description: string }>) {
1112
  return (
1113
    <div className="setting">
1114
      <div>
1115
        <strong>{label}</strong>
1116
        <p>{description}</p>
1117
      </div>
1118
      <div>{children}</div>
1119
    </div>
1120
  );
1121
}
1122
function Toggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
1123
  return (
1124
    <button
1125
      className={checked ? 'toggle on' : 'toggle'}
1126
      role="switch"
1127
      aria-checked={checked}
1128
      onClick={() => onChange(!checked)}
1129
    >
1130
      <span />
1131
    </button>
1132
  );
1133
}
1134
function Loading({ compact }: { compact?: boolean }) {
1135
  return (
1136
    <div className={compact ? 'loading compact' : 'loading'}>
1137
      <span className="mark">OC</span>
1138
      <p>Verifying session…</p>
1139
    </div>
1140
  );
1141
}
1142
function initials(value: string) {
1143
  return value
1144
    .split(/\s+|@/)
1145
    .slice(0, 2)
1146
    .map((part) => part[0]?.toUpperCase())
1147
    .join('');
1148
}
1149
function timestamp(item: Record<string, any>) {
1150
  return String(item.timestamp ?? item.completedAt ?? item.createdAt ?? new Date(0).toISOString());
1151
}
1152
1153
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
1154
  throw new Error('Auth0 public configuration is incomplete');
1155
createRoot(document.getElementById('root')!).render(
1156
  <React.StrictMode>
1157
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
1158
  </React.StrictMode>,
1159
);