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

Viewing files at commit 1da1b7ef4016Commit detailsView latest

apps/dashboard/src/main.tsx 1323 lines · 42.9 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: true;
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: true,
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
  useEffect(() => {
177
    if (!session) return;
178
    const refresh = () => {
179
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
180
    };
181
    const timer = window.setInterval(refresh, 10_000);
182
    document.addEventListener('visibilitychange', refresh);
183
    return () => {
184
      window.clearInterval(timer);
185
      document.removeEventListener('visibilitychange', refresh);
186
    };
187
  }, [session, refreshDashboard]);
188
189
  const navigate = (next: Page) => {
190
    history.pushState({}, '', `/${next}`);
191
    setPage(next);
192
  };
193
194
  if (session === undefined) return <Loading />;
195
  if (!session) return <Login />;
196
197
  return (
198
    <div className="appShell">
199
      <aside>
200
        <div className="brand">
201
          <div className="mark">OC</div>
202
          <div>
203
            <strong>OpenClasp</strong>
204
            <small>ASSURANCE NETWORK</small>
205
          </div>
206
        </div>
207
        <nav>
208
          <Nav page="dashboard" active={page} onClick={navigate} label="Overview" glyph="⌂" />
209
          <Nav page="history" active={page} onClick={navigate} label="History" glyph="≡" />
210
          <Nav page="agents" active={page} onClick={navigate} label="Agents" glyph="◇" />
211
          <Nav page="insights" active={page} onClick={navigate} label="Insights" glyph="⌁" />
212
          <Nav page="connect" active={page} onClick={navigate} label="Connect" glyph="+" />
213
          <Nav page="settings" active={page} onClick={navigate} label="Settings" glyph="⚙" />
214
        </nav>
215
        <button
216
          className="themeSwitch"
217
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
218
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
219
        >
220
          <span>{theme === 'dark' ? '☼' : '◐'}</span>
221
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
222
        </button>
223
        <div className="privacyStamp">
224
          <span className="liveDot" />
225
          <div>
226
            <strong>Privacy separated</strong>
227
            <small>Gateway bodies expire in 24h</small>
228
          </div>
229
        </div>
230
        <button className="account" onClick={() => void signOut()}>
231
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
232
          <div>
233
            <strong>{session.user.name || 'OpenClasp user'}</strong>
234
            <small>{session.user.email || 'Sign out'}</small>
235
          </div>
236
          <b>↗</b>
237
        </button>
238
      </aside>
239
      <main>
240
        {error && <div className="errorBar">{error}</div>}
241
        {loading ? (
242
          <Loading compact />
243
        ) : (
244
          <PageContent
245
            page={page}
246
            data={data}
247
            settings={settings}
248
            setSettings={setSettings}
249
            navigate={navigate}
250
            refreshDashboard={refreshDashboard}
251
          />
252
        )}
253
      </main>
254
    </div>
255
  );
256
}
257
258
function Login() {
259
  const [error, setError] = useState('');
260
  const continueWith = async (provider: 'google' | 'github') => {
261
    setError('');
262
    try {
263
      await beginAuth(provider);
264
    } catch {
265
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
266
    }
267
  };
268
  return (
269
    <div className="loginPage">
270
      <section className="loginPitch">
271
        <div className="brand">
272
          <div className="mark">OC</div>
273
          <strong>OpenClasp</strong>
274
        </div>
275
        <div>
276
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
277
          <h1>Know who your agent is dealing with.</h1>
278
          <p>
279
            Verified identities, signed outcomes, private warnings, and contextual
280
            reliability—without using message bodies for scoring.
281
          </p>
282
        </div>
283
        <div className="loginProof">
284
          <span>01</span> User-owned history<span>02</span> No universal trust score<span>03</span>{' '}
285
          Explicit network consent
286
        </div>
287
      </section>
288
      <section className="loginCard">
289
        <div>
290
          <p className="eyebrow">SECURE ACCESS</p>
291
          <h2>Sign in to OpenClasp</h2>
292
          <p>
293
            Manage connected agents, review signed history, and control what contributes to the
294
            network.
295
          </p>
296
        </div>
297
        <div className="socialButtons">
298
          <button onClick={() => void continueWith('google')}>
299
            <span className="google">G</span> Continue with Google
300
          </button>
301
          <button onClick={() => void continueWith('github')}>
302
            <span>◉</span> Continue with GitHub
303
          </button>
304
        </div>
305
        {error && <div className="loginError">{error}</div>}
306
        <small>
307
          Google and GitHub handle authentication. OpenClasp never receives your password.
308
        </small>
309
      </section>
310
    </div>
311
  );
312
}
313
314
let callbackStarted = false;
315
function AuthCallback() {
316
  const [error, setError] = useState('');
317
  useEffect(() => {
318
    if (callbackStarted) return;
319
    callbackStarted = true;
320
    const finish = async () => {
321
      const query = new URLSearchParams(location.search);
322
      const oauthError = query.get('error_description') ?? query.get('error');
323
      if (oauthError) throw new Error(oauthError);
324
      const code = query.get('code');
325
      const returnedState = query.get('state');
326
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
327
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
328
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
329
      sessionStorage.removeItem(authTransactionKey);
330
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
331
      const response = await fetch('/api/session', {
332
        method: 'POST',
333
        credentials: 'same-origin',
334
        headers: { 'content-type': 'application/json' },
335
        body: JSON.stringify({
336
          code,
337
          codeVerifier: transaction.verifier,
338
        }),
339
      });
340
      if (!response.ok) throw new Error('Auth0 token exchange failed');
341
      location.replace('/dashboard');
342
    };
343
    void finish().catch((reason: unknown) =>
344
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
345
    );
346
  }, []);
347
  if (error)
348
    return (
349
      <div className="loading">
350
        <span className="mark">OC</span>
351
        <p>{error}</p>
352
        <a href="/login">Return to login</a>
353
      </div>
354
    );
355
  return <Loading />;
356
}
357
358
function PageContent({
359
  page,
360
  data,
361
  settings,
362
  setSettings,
363
  navigate,
364
  refreshDashboard,
365
}: {
366
  page: Page;
367
  data: DashboardData;
368
  settings: Settings;
369
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
370
  navigate: (page: Page) => void;
371
  refreshDashboard: () => Promise<void>;
372
}) {
373
  if (page === 'history') return <History data={data} />;
374
  if (page === 'agents')
375
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} />;
376
  if (page === 'insights') return <Insights data={data} />;
377
  if (page === 'connect') return <Connect data={data} refreshDashboard={refreshDashboard} />;
378
  if (page === 'settings') return <SettingsPage settings={settings} setSettings={setSettings} />;
379
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} />;
380
}
381
382
function Overview({
383
  data,
384
  navigate,
385
  refreshDashboard,
386
}: {
387
  data: DashboardData;
388
  navigate: (page: Page) => void;
389
  refreshDashboard: () => Promise<void>;
390
}) {
391
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
392
  const warnings = data.events.filter((item) =>
393
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
394
  ).length;
395
  const publishedIds = new Set(
396
    data.publications
397
      .filter((publication) => publication.published)
398
      .map((publication) => String(publication.agentId)),
399
  );
400
  const readyAgents = data.agents.filter((agent) => publishedIds.has(String(agent.agentId))).length;
401
  const onlineAgents = data.agents.filter((agent) => agent.presence?.status === 'online').length;
402
  const pendingInvitations = data.federatedInteractions.filter(
403
    (interaction) => interaction.status === 'pending',
404
  ).length;
405
  return (
406
    <>
407
      <PageHead
408
        eyebrow="NETWORK OVERVIEW"
409
        title="Assurance, at a glance."
410
        action="Connect agent"
411
        onAction={() => navigate('connect')}
412
      />
413
      <section
414
        className={`readiness ${readyAgents === data.agents.length && readyAgents ? 'ready' : ''}`}
415
      >
416
        <div>
417
          <span className="statusOrb">{readyAgents ? '✓' : '!'}</span>
418
          <div>
419
            <strong>
420
              {readyAgents === data.agents.length && readyAgents
421
                ? 'Your agent network is ready'
422
                : `${readyAgents} of ${data.agents.length} agents can receive A2A work`}
423
            </strong>
424
            <small>
425
              Safe matching requests activate automatically. Everything else waits for approval.
426
            </small>
427
          </div>
428
        </div>
429
        <button className="secondary" onClick={() => navigate('agents')}>
430
          Manage automation
431
        </button>
432
      </section>
433
      <section className="metrics">
434
        <Metric
435
          label="Online agents"
436
          value={onlineAgents}
437
          note={`${data.agents.length - onlineAgents} offline · 2 min window`}
438
        />
439
        <Metric
440
          label="Interactions"
441
          value={data.interactions.length + data.federatedInteractions.length}
442
          note="local and shared"
443
        />
444
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
445
        <Metric
446
          label="Needs review"
447
          value={warnings + pendingInvitations}
448
          note="warnings or approvals"
449
          warn={warnings + pendingInvitations > 0}
450
        />
451
      </section>
452
      <Invitations data={data} refreshDashboard={refreshDashboard} />
453
      <section className="contentGrid">
454
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
455
          <Timeline events={data.events.slice(-6).reverse()} />
456
          <TextButton onClick={() => navigate('history')}>View complete history →</TextButton>
457
        </Panel>
458
        <Panel
459
          title="Reliability context"
460
          subtitle="Task-specific profiles, never a universal score"
461
        >
462
          {data.profiles.length ? (
463
            data.profiles
464
              .slice(0, 4)
465
              .map((profile) => (
466
                <Profile key={`${profile.agentId}-${profile.taskCategory}`} profile={profile} />
467
              ))
468
          ) : (
469
            <Empty
470
              title="No reliability profile yet"
471
              text="Profiles appear after signed receipts and eligible bilateral feedback."
472
            />
473
          )}
474
        </Panel>
475
      </section>
476
    </>
477
  );
478
}
479
480
function History({ data }: { data: DashboardData }) {
481
  const records = useMemo(
482
    () =>
483
      [
484
        ...data.interactions.map((value) => ({ ...value, _kind: 'interaction' })),
485
        ...data.federatedInteractions.map((value) => ({
486
          ...value,
487
          _kind: 'federated interaction',
488
        })),
489
        ...data.events.map((value) => ({ ...value, _kind: 'event' })),
490
        ...data.receipts.map((value) => ({ ...value, _kind: 'receipt' })),
491
        ...data.conflicts.map((value) => ({ ...value, _kind: 'dispute' })),
492
      ].sort((a, b) => Date.parse(timestamp(b)) - Date.parse(timestamp(a))),
493
    [data],
494
  );
495
  return (
496
    <>
497
      <PageHead eyebrow="AUDIT HISTORY" title="What happened, and what proves it." />
498
      <Panel
499
        title="Account history"
500
        subtitle="Only structured metadata, signatures, hashes, and permitted evidence are hosted"
501
      >
502
        {records.length ? (
503
          records.map((item, index) => (
504
            <HistoryRow
505
              key={String(
506
                (item as Record<string, any>).eventId ??
507
                  (item as Record<string, any>).receiptId ??
508
                  (item as Record<string, any>).interactionId ??
509
                  index,
510
              )}
511
              item={item}
512
            />
513
          ))
514
        ) : (
515
          <Empty
516
            title="No interactions recorded"
517
            text="Connect an agent and start an assured interaction. Raw message bodies will not appear here."
518
          />
519
        )}
520
      </Panel>
521
    </>
522
  );
523
}
524
525
function Agents({
526
  data,
527
  navigate,
528
  refreshDashboard,
529
}: {
530
  data: DashboardData;
531
  navigate: (page: Page) => void;
532
  refreshDashboard: () => Promise<void>;
533
}) {
534
  const [working, setWorking] = useState('');
535
  const [error, setError] = useState('');
536
  const saveAutomation = async (
537
    agentId: string,
538
    value: {
539
      autoPublish: boolean;
540
      autoAcceptPolicy: 'off' | 'safe_matching';
541
      autoAcceptTaskCategories: string[];
542
    },
543
  ) => {
544
    setWorking(agentId);
545
    setError('');
546
    try {
547
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
548
        method: 'PUT',
549
        body: JSON.stringify(value),
550
      });
551
      await refreshDashboard();
552
    } catch (reason) {
553
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
554
    } finally {
555
      setWorking('');
556
    }
557
  };
558
  return (
559
    <>
560
      <PageHead
561
        eyebrow="IDENTITY REGISTRY"
562
        title="Your connected agents."
563
        action="Connect agent"
564
        onAction={() => navigate('connect')}
565
      />
566
      <div className="notice">
567
        <strong>One approval, then automatic.</strong> OpenClasp creates and hosts the agent's A2A
568
        endpoint. Safe matching tasks can be accepted automatically; sensitive or mismatched
569
        requests still require review.
570
      </div>
571
      {error && <div className="errorBar">{error}</div>}
572
      <section className="agentGrid">
573
        {data.agents.length ? (
574
          data.agents.map((agent) => (
575
            <AgentCard
576
              key={agent.agentId}
577
              agent={agent}
578
              projectName={
579
                data.projects.find((project) => project.projectId === agent.projectId)?.name
580
              }
581
              published={data.publications.some(
582
                (publication) => publication.agentId === agent.agentId && publication.published,
583
              )}
584
              working={working === agent.agentId}
585
              onSave={(value) => saveAutomation(agent.agentId, value)}
586
            />
587
          ))
588
        ) : (
589
          <Empty
590
            title="No agents connected"
591
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
592
            action="Open connection guide"
593
            onAction={() => navigate('connect')}
594
          />
595
        )}
596
      </section>
597
    </>
598
  );
599
}
600
601
function Invitations({
602
  data,
603
  refreshDashboard,
604
}: {
605
  data: DashboardData;
606
  refreshDashboard: () => Promise<void>;
607
}) {
608
  const [working, setWorking] = useState('');
609
  const [error, setError] = useState('');
610
  const ownedAgentIds = useMemo(
611
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
612
    [data.agents],
613
  );
614
  const incoming = data.federatedInteractions.filter(
615
    (interaction) =>
616
      interaction.status === 'pending' && ownedAgentIds.has(String(interaction.responderAgentId)),
617
  );
618
  const respond = async (interactionId: string, agentId: string, decision: 'accept' | 'reject') => {
619
    setWorking(interactionId);
620
    setError('');
621
    try {
622
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/respond`, {
623
        method: 'POST',
624
        body: JSON.stringify({ agentId, decision }),
625
      });
626
      await refreshDashboard();
627
    } catch (reason) {
628
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
629
    } finally {
630
      setWorking('');
631
    }
632
  };
633
  if (!incoming.length && !data.federatedInteractions.length) return null;
634
  return (
635
    <Panel
636
      title={incoming.length ? `Agent invitations (${incoming.length})` : 'Shared interactions'}
637
      subtitle="Both accounts see the same immutable contract and acceptance state"
638
    >
639
      {error ? <div className="errorBar">{error}</div> : null}
640
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
641
        <article className="setupRequest" key={interaction.interactionId}>
642
          <div>
643
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
644
            <small>
645
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
646
            </small>
647
            <small>Contract: {String(interaction.termsHash).slice(0, 16)}…</small>
648
            {interaction.status === 'active' &&
649
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
650
              'policy_auto_accept' ? (
651
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
652
            ) : null}
653
          </div>
654
          {interaction.status === 'pending' &&
655
          ownedAgentIds.has(String(interaction.responderAgentId)) ? (
656
            <div className="decisionButtons">
657
              <button
658
                className="secondary"
659
                disabled={working === interaction.interactionId}
660
                onClick={() =>
661
                  void respond(interaction.interactionId, interaction.responderAgentId, 'reject')
662
                }
663
              >
664
                Reject
665
              </button>
666
              <button
667
                className="primary"
668
                disabled={working === interaction.interactionId}
669
                onClick={() =>
670
                  void respond(interaction.interactionId, interaction.responderAgentId, 'accept')
671
                }
672
              >
673
                {working === interaction.interactionId ? 'Working…' : 'Accept contract'}
674
              </button>
675
            </div>
676
          ) : null}
677
        </article>
678
      ))}
679
    </Panel>
680
  );
681
}
682
683
function Insights({ data }: { data: DashboardData }) {
684
  return (
685
    <>
686
      <PageHead eyebrow="BEHAVIOURAL CONTEXT" title="Evidence, not reputation theatre." />
687
      <div className="notice">
688
        <strong>Context matters.</strong> OpenClasp profiles an agent by task category and version.
689
        It does not produce a single universal trust score.
690
      </div>
691
      <section className="agentGrid">
692
        {data.profiles.length ? (
693
          data.profiles.map((profile) => (
694
            <Panel
695
              key={`${profile.agentId}-${profile.taskCategory}`}
696
              title={profile.agentId}
697
              subtitle={`${profile.taskCategory} · v${profile.agentVersion}`}
698
            >
699
              <Profile profile={profile} expanded />
700
            </Panel>
701
          ))
702
        ) : (
703
          <Empty
704
            title="Not enough verified evidence"
705
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
706
          />
707
        )}
708
      </section>
709
    </>
710
  );
711
}
712
713
function Connect({
714
  data,
715
  refreshDashboard,
716
}: {
717
  data: DashboardData;
718
  refreshDashboard: () => Promise<void>;
719
}) {
720
  const endpoint = 'https://openclasp.vercel.app/mcp';
721
  const [copied, setCopied] = useState(false);
722
  const [working, setWorking] = useState('');
723
  const [decisionError, setDecisionError] = useState('');
724
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
725
  useEffect(() => {
726
    const poll = () => void refreshDashboard().catch(() => undefined);
727
    poll();
728
    const timer = window.setInterval(poll, 5000);
729
    return () => window.clearInterval(timer);
730
  }, [refreshDashboard]);
731
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
732
    setWorking(requestId);
733
    setDecisionError('');
734
    try {
735
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
736
        method: 'POST',
737
      });
738
      await refreshDashboard();
739
    } catch (reason) {
740
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
741
    } finally {
742
      setWorking('');
743
    }
744
  };
745
  return (
746
    <>
747
      <PageHead eyebrow="AGENT CONNECTION" title="Add OpenClasp to an agent." />
748
      {pending.length > 0 && (
749
        <section className="setupRequests">
750
          <div>
751
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
752
            <h2>Approve agent setup</h2>
753
            <p>
754
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
755
            </p>
756
          </div>
757
          {pending.map((request) => (
758
            <article className="setupRequest" key={request.requestId}>
759
              <div>
760
                <strong>{request.agentName ?? 'Switch installation'}</strong>
761
                <small>
762
                  {request.projectName ?? request.existingAgentId} · {request.framework}
763
                </small>
764
                {!!request.capabilities?.length && (
765
                  <div className="tags">
766
                    {request.capabilities.slice(0, 5).map((capability: string) => (
767
                      <span key={capability}>{capability}</span>
768
                    ))}
769
                  </div>
770
                )}
771
                {!!request.limitations?.length && (
772
                  <small>Limitations: {request.limitations.join(' · ')}</small>
773
                )}
774
                <div className="automationPreview">
775
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
776
                  <span>
777
                    {request.autoAcceptPolicy === 'safe_matching'
778
                      ? 'Auto-accept safe matches'
779
                      : 'Review every request'}
780
                  </span>
781
                </div>
782
              </div>
783
              <div className="decisionButtons">
784
                <button
785
                  className="secondary"
786
                  disabled={working === request.requestId}
787
                  onClick={() => void decide(request.requestId, 'reject')}
788
                >
789
                  Reject
790
                </button>
791
                <button
792
                  className="primary"
793
                  disabled={working === request.requestId}
794
                  onClick={() => void decide(request.requestId, 'approve')}
795
                >
796
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
797
                </button>
798
              </div>
799
            </article>
800
          ))}
801
          {decisionError && <div className="loginError">{decisionError}</div>}
802
        </section>
803
      )}
804
      <section className="connectLayout">
805
        <Panel title="Connect once" subtitle="OpenClasp handles the routine steps afterward">
806
          <div className="endpoint">
807
            <code>{endpoint}</code>
808
            <button
809
              onClick={() => {
810
                void navigator.clipboard.writeText(endpoint);
811
                setCopied(true);
812
              }}
813
            >
814
              {copied ? 'Copied' : 'Copy'}
815
            </button>
816
          </div>
817
          <ol>
818
            <li>Add this MCP URL and complete the OAuth sign-in.</li>
819
            <li>
820
              Tell the agent: <code>Set yourself up on OpenClasp</code>.
821
            </li>
822
            <li>
823
              Approve its identity and automation policy here once. That is the last routine manual
824
              step.
825
            </li>
826
          </ol>
827
        </Panel>
828
        <Panel
829
          title="What becomes automatic"
830
          subtitle="Safe defaults with deterministic approval boundaries"
831
        >
832
          <ul className="checkList">
833
            <li>Agent Card publication and discovery</li>
834
            <li>Contract defaults inferred from the task</li>
835
            <li>Safe matching invitations accepted immediately</li>
836
            <li>Ready-to-send A2A endpoint, headers, and metadata</li>
837
            <li>Risky requests routed to human review</li>
838
          </ul>
839
        </Panel>
840
      </section>
841
    </>
842
  );
843
}
844
845
function SettingsPage({
846
  settings,
847
  setSettings,
848
}: {
849
  settings: Settings;
850
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
851
}) {
852
  const [saving, setSaving] = useState(false);
853
  const [saved, setSaved] = useState(false);
854
  const save = useCallback(async () => {
855
    setSaving(true);
856
    setSaved(false);
857
    const updated = (await api('/v0.1/settings', {
858
      method: 'PUT',
859
      body: JSON.stringify(settings),
860
    })) as Settings;
861
    setSettings(updated);
862
    setSaving(false);
863
    setSaved(true);
864
  }, [settings, setSettings]);
865
  return (
866
    <>
867
      <PageHead eyebrow="ACCOUNT CONTROLS" title="Privacy and network settings." />
868
      <section className="settingsCard">
869
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
870
          <input
871
            value={settings.displayName}
872
            onChange={(event) =>
873
              setSettings((value) => ({ ...value, displayName: event.target.value }))
874
            }
875
            placeholder="Team or operator name"
876
          />
877
        </Setting>
878
        <Setting
879
          label="Network contribution"
880
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
881
        >
882
          <Toggle
883
            checked={settings.contributionEnabled}
884
            onChange={(checked) =>
885
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
886
            }
887
          />
888
        </Setting>
889
        <Setting
890
          label="Evidence sharing"
891
          description="Controls when permitted evidence references may be shared with interaction participants."
892
        >
893
          <select
894
            value={settings.evidenceSharing}
895
            onChange={(event) =>
896
              setSettings((value) => ({
897
                ...value,
898
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
899
              }))
900
            }
901
          >
902
            <option value="never">Never</option>
903
            <option value="ask">Ask every time</option>
904
            <option value="contract_only">When contract permits</option>
905
          </select>
906
        </Setting>
907
        <Setting
908
          label="Structured record retention"
909
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
910
        >
911
          <select
912
            value={settings.retentionDays}
913
            onChange={(event) =>
914
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
915
            }
916
          >
917
            <option value={7}>7 days</option>
918
            <option value={30}>30 days</option>
919
            <option value={90}>90 days</option>
920
            <option value={365}>1 year</option>
921
            <option value={0}>No automatic deletion</option>
922
          </select>
923
        </Setting>
924
        <Setting
925
          label="Gateway message bodies"
926
          description="Encrypted at rest, deleted on acknowledgement, and automatically expired after 24 hours. Never used for profiles."
927
        >
928
          <span className="locked">24H MAX</span>
929
        </Setting>
930
        <div className="saveRow">
931
          <button className="primary" onClick={() => void save()} disabled={saving}>
932
            {saving ? 'Saving…' : 'Save settings'}
933
          </button>
934
          {saved && <span>Saved</span>}
935
        </div>
936
      </section>
937
    </>
938
  );
939
}
940
941
function PageHead({
942
  eyebrow,
943
  title,
944
  action,
945
  onAction,
946
}: {
947
  eyebrow: string;
948
  title: string;
949
  action?: string;
950
  onAction?: () => void;
951
}) {
952
  return (
953
    <header className="pageHead">
954
      <div>
955
        <p className="eyebrow">{eyebrow}</p>
956
        <h1>{title}</h1>
957
      </div>
958
      {action && (
959
        <button className="primary" onClick={onAction}>
960
          {action}
961
        </button>
962
      )}
963
    </header>
964
  );
965
}
966
function Nav({
967
  page,
968
  active,
969
  onClick,
970
  label,
971
  glyph,
972
}: {
973
  page: Page;
974
  active: Page;
975
  onClick: (page: Page) => void;
976
  label: string;
977
  glyph: string;
978
}) {
979
  return (
980
    <button className={active === page ? 'active' : ''} onClick={() => onClick(page)}>
981
      <span>{glyph}</span>
982
      {label}
983
    </button>
984
  );
985
}
986
function Metric({
987
  label,
988
  value,
989
  note,
990
  warn,
991
}: {
992
  label: string;
993
  value: number;
994
  note: string;
995
  warn?: boolean;
996
}) {
997
  return (
998
    <article className={warn ? 'metric warn' : 'metric'}>
999
      <span>{label}</span>
1000
      <strong>{String(value).padStart(2, '0')}</strong>
1001
      <small>{note}</small>
1002
    </article>
1003
  );
1004
}
1005
function Panel({
1006
  title,
1007
  subtitle,
1008
  children,
1009
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
1010
  return (
1011
    <section className="panel">
1012
      <div className="panelHead">
1013
        <div>
1014
          <h2>{title}</h2>
1015
          <p>{subtitle}</p>
1016
        </div>
1017
      </div>
1018
      {children}
1019
    </section>
1020
  );
1021
}
1022
function Timeline({ events }: { events: Record<string, any>[] }) {
1023
  return events.length ? (
1024
    <div className="timeline">
1025
      {events.map((event) => (
1026
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
1027
      ))}
1028
    </div>
1029
  ) : (
1030
    <Empty
1031
      title="Nothing recorded yet"
1032
      text="Activity appears after your connected agents submit signed structured events."
1033
    />
1034
  );
1035
}
1036
function HistoryRow({ item }: { item: Record<string, any> }) {
1037
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
1038
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
1039
  return (
1040
    <article className="historyRow">
1041
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
1042
      <div>
1043
        <strong>{kind}</strong>
1044
        <small>
1045
          {id} · {new Date(timestamp(item)).toLocaleString()}
1046
        </small>
1047
      </div>
1048
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
1049
    </article>
1050
  );
1051
}
1052
function AgentCard({
1053
  agent,
1054
  projectName,
1055
  published,
1056
  working,
1057
  onSave,
1058
}: {
1059
  agent: Record<string, any>;
1060
  projectName?: string;
1061
  published: boolean;
1062
  working: boolean;
1063
  onSave: (value: {
1064
    autoPublish: boolean;
1065
    autoAcceptPolicy: 'off' | 'safe_matching';
1066
    autoAcceptTaskCategories: string[];
1067
  }) => void;
1068
}) {
1069
  const [editing, setEditing] = useState(false);
1070
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
1071
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
1072
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
1073
  );
1074
  const [categories, setCategories] = useState<string>(
1075
    (agent.autoAcceptTaskCategories?.length
1076
      ? agent.autoAcceptTaskCategories
1077
      : (agent.capabilities ?? [])
1078
    ).join(', '),
1079
  );
1080
  const ready = published;
1081
  const online = agent.presence?.status === 'online';
1082
  const endpoint = `${window.location.origin}/a2a/${encodeURIComponent(agent.agentId)}`;
1083
  const identityLabel = agent.revoked
1084
    ? 'REVOKED'
1085
    : agent.identityMode === 'oauth_installation'
1086
      ? 'AUTHENTICATED'
1087
      : 'VERIFIED';
1088
  return (
1089
    <article className="agentCard">
1090
      <div className="agentTop">
1091
        <span className="agentGlyph">◇</span>
1092
        <div className="agentBadges">
1093
          <b className={online ? 'onlineBadge' : 'offlineBadge'}>
1094
            ● {online ? 'ONLINE' : 'OFFLINE'}
1095
          </b>
1096
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
1097
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
1098
        </div>
1099
      </div>
1100
      <h2>{agent.name ?? agent.agentId}</h2>
1101
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
1102
      <div className="tags">
1103
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
1104
          <span key={capability}>{capability}</span>
1105
        ))}
1106
      </div>
1107
      <small>
1108
        {agent.framework ? `${agent.framework} · ` : ''}
1109
        {agent.identityMode === 'oauth_installation' ? 'OAuth-bound · ' : 'Ed25519 · '}Created{' '}
1110
        {new Date(agent.createdAt).toLocaleDateString()}
1111
      </small>
1112
      <small>
1113
        {agent.presence?.lastSeenAt
1114
          ? `Last MCP activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
1115
          : 'No MCP activity recorded yet'}
1116
      </small>
1117
      {published ? (
1118
        <a
1119
          href={`/agents/${encodeURIComponent(agent.agentId)}/card.json`}
1120
          target="_blank"
1121
          rel="noreferrer"
1122
        >
1123
          Public Agent Card ↗
1124
        </a>
1125
      ) : null}
1126
      <div className="automationSummary">
1127
        <span>{published ? '● Public discovery' : '○ Private'}</span>
1128
        <span>↗ OpenClasp A2A gateway</span>
1129
        <span>
1130
          {agent.autoAcceptPolicy === 'safe_matching'
1131
            ? '⚡ Safe tasks automatic'
1132
            : '◷ Manual approval'}
1133
        </span>
1134
      </div>
1135
      {editing ? (
1136
        <div className="automationForm">
1137
          <label>
1138
            <span>Hosted A2A endpoint</span>
1139
            <input type="url" value={endpoint} readOnly />
1140
          </label>
1141
          <label>
1142
            <span>Invitation policy</span>
1143
            <select
1144
              value={autoAcceptPolicy}
1145
              onChange={(event) =>
1146
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
1147
              }
1148
            >
1149
              <option value="safe_matching">Auto-accept safe matches</option>
1150
              <option value="off">Review every request</option>
1151
            </select>
1152
          </label>
1153
          <label>
1154
            <span>Safe task categories</span>
1155
            <input
1156
              value={categories}
1157
              onChange={(event) => setCategories(event.target.value)}
1158
              placeholder="research, planning, coding"
1159
            />
1160
          </label>
1161
          <label className="checkControl">
1162
            <input
1163
              type="checkbox"
1164
              checked={autoPublish}
1165
              onChange={(event) => setAutoPublish(event.target.checked)}
1166
            />
1167
            <span>Publish and keep Agent Card updated</span>
1168
          </label>
1169
          <div className="agentActions">
1170
            <button className="secondary" onClick={() => setEditing(false)}>
1171
              Cancel
1172
            </button>
1173
            <button
1174
              className="primary"
1175
              disabled={working || agent.status === 'revoked'}
1176
              onClick={() =>
1177
                onSave({
1178
                  autoPublish,
1179
                  autoAcceptPolicy,
1180
                  autoAcceptTaskCategories: categories
1181
                    .split(',')
1182
                    .map((value) => value.trim())
1183
                    .filter(Boolean),
1184
                })
1185
              }
1186
            >
1187
              {working ? 'Saving…' : 'Save automation'}
1188
            </button>
1189
          </div>
1190
        </div>
1191
      ) : (
1192
        <button
1193
          className="secondary"
1194
          disabled={working || agent.status === 'revoked'}
1195
          onClick={() => setEditing(true)}
1196
        >
1197
          Configure automation
1198
        </button>
1199
      )}
1200
    </article>
1201
  );
1202
}
1203
function Profile({ profile, expanded }: { profile: Record<string, any>; expanded?: boolean }) {
1204
  const dimensions = [
1205
    ['Completion', profile.completion],
1206
    ['Scope', profile.scope],
1207
    ['Evidence', profile.evidence],
1208
    ...(expanded
1209
      ? [
1210
          ['Communication', profile.communication],
1211
          ['Deadline', profile.deadline],
1212
        ]
1213
      : []),
1214
  ] as [string, number][];
1215
  return (
1216
    <article className="profile">
1217
      <div>
1218
        <strong>{profile.agentId}</strong>
1219
        <small>
1220
          {profile.taskCategory} · {profile.sampleSize} outcome(s)
1221
        </small>
1222
      </div>
1223
      {dimensions.map(([label, value]) => (
1224
        <Meter key={label} label={label} value={value ?? 0} />
1225
      ))}
1226
    </article>
1227
  );
1228
}
1229
function Meter({ label, value }: { label: string; value: number }) {
1230
  return (
1231
    <div className="meter">
1232
      <span>{label}</span>
1233
      <i>
1234
        <b style={{ width: `${Math.round(value * 100)}%` }} />
1235
      </i>
1236
      <strong>{Math.round(value * 100)}%</strong>
1237
    </div>
1238
  );
1239
}
1240
function Empty({
1241
  title,
1242
  text,
1243
  action,
1244
  onAction,
1245
}: {
1246
  title: string;
1247
  text: string;
1248
  action?: string;
1249
  onAction?: () => void;
1250
}) {
1251
  return (
1252
    <div className="empty">
1253
      <span>◇</span>
1254
      <strong>{title}</strong>
1255
      <p>{text}</p>
1256
      {action && (
1257
        <button className="secondary" onClick={onAction}>
1258
          {action}
1259
        </button>
1260
      )}
1261
    </div>
1262
  );
1263
}
1264
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
1265
  return (
1266
    <button className="textButton" onClick={onClick}>
1267
      {children}
1268
    </button>
1269
  );
1270
}
1271
function Setting({
1272
  label,
1273
  description,
1274
  children,
1275
}: React.PropsWithChildren<{ label: string; description: string }>) {
1276
  return (
1277
    <div className="setting">
1278
      <div>
1279
        <strong>{label}</strong>
1280
        <p>{description}</p>
1281
      </div>
1282
      <div>{children}</div>
1283
    </div>
1284
  );
1285
}
1286
function Toggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
1287
  return (
1288
    <button
1289
      className={checked ? 'toggle on' : 'toggle'}
1290
      role="switch"
1291
      aria-checked={checked}
1292
      onClick={() => onChange(!checked)}
1293
    >
1294
      <span />
1295
    </button>
1296
  );
1297
}
1298
function Loading({ compact }: { compact?: boolean }) {
1299
  return (
1300
    <div className={compact ? 'loading compact' : 'loading'}>
1301
      <span className="mark">OC</span>
1302
      <p>Verifying session…</p>
1303
    </div>
1304
  );
1305
}
1306
function initials(value: string) {
1307
  return value
1308
    .split(/\s+|@/)
1309
    .slice(0, 2)
1310
    .map((part) => part[0]?.toUpperCase())
1311
    .join('');
1312
}
1313
function timestamp(item: Record<string, any>) {
1314
  return String(item.timestamp ?? item.completedAt ?? item.createdAt ?? new Date(0).toISOString());
1315
}
1316
1317
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
1318
  throw new Error('Auth0 public configuration is incomplete');
1319
createRoot(document.getElementById('root')!).render(
1320
  <React.StrictMode>
1321
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
1322
  </React.StrictMode>,
1323
);