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

Viewing files at commit 5eea80c71925Commit detailsView latest

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