@clef-sh/ui 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/client/assets/index-DPWHjBbB.js +34 -0
  2. package/dist/client/assets/index-qsLTYpc9.css +2 -0
  3. package/dist/client/clef.svg +2 -0
  4. package/dist/client/index.html +3 -31
  5. package/dist/client-lib/components/Button.d.ts +1 -1
  6. package/dist/client-lib/components/Button.d.ts.map +1 -1
  7. package/dist/client-lib/components/CopyButton.d.ts.map +1 -1
  8. package/dist/client-lib/components/EnvBadge.d.ts.map +1 -1
  9. package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
  10. package/dist/client-lib/components/Sidebar.d.ts +1 -1
  11. package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
  12. package/dist/client-lib/components/StatusDot.d.ts.map +1 -1
  13. package/dist/client-lib/components/SyncPanel.d.ts.map +1 -1
  14. package/dist/client-lib/components/TopBar.d.ts +6 -0
  15. package/dist/client-lib/components/TopBar.d.ts.map +1 -1
  16. package/dist/client-lib/primitives/Badge.d.ts +11 -0
  17. package/dist/client-lib/primitives/Badge.d.ts.map +1 -0
  18. package/dist/client-lib/primitives/Card.d.ts +28 -0
  19. package/dist/client-lib/primitives/Card.d.ts.map +1 -0
  20. package/dist/client-lib/primitives/Dialog.d.ts +30 -0
  21. package/dist/client-lib/primitives/Dialog.d.ts.map +1 -0
  22. package/dist/client-lib/primitives/EmptyState.d.ts +10 -0
  23. package/dist/client-lib/primitives/EmptyState.d.ts.map +1 -0
  24. package/dist/client-lib/primitives/Field.d.ts +36 -0
  25. package/dist/client-lib/primitives/Field.d.ts.map +1 -0
  26. package/dist/client-lib/primitives/Input.d.ts +6 -0
  27. package/dist/client-lib/primitives/Input.d.ts.map +1 -0
  28. package/dist/client-lib/primitives/Stat.d.ts +11 -0
  29. package/dist/client-lib/primitives/Stat.d.ts.map +1 -0
  30. package/dist/client-lib/primitives/Table.d.ts +37 -0
  31. package/dist/client-lib/primitives/Table.d.ts.map +1 -0
  32. package/dist/client-lib/primitives/Tabs.d.ts +29 -0
  33. package/dist/client-lib/primitives/Tabs.d.ts.map +1 -0
  34. package/dist/client-lib/primitives/Toast.d.ts +16 -0
  35. package/dist/client-lib/primitives/Toast.d.ts.map +1 -0
  36. package/dist/client-lib/primitives/Toolbar.d.ts +29 -0
  37. package/dist/client-lib/primitives/Toolbar.d.ts.map +1 -0
  38. package/dist/client-lib/primitives/index.d.ts +23 -0
  39. package/dist/client-lib/primitives/index.d.ts.map +1 -0
  40. package/dist/client-lib/theme.d.ts +18 -41
  41. package/dist/client-lib/theme.d.ts.map +1 -1
  42. package/dist/server/api.d.ts.map +1 -1
  43. package/dist/server/api.js +215 -0
  44. package/dist/server/api.js.map +1 -1
  45. package/dist/server/envelope.d.ts +15 -0
  46. package/dist/server/envelope.d.ts.map +1 -0
  47. package/dist/server/envelope.js +310 -0
  48. package/dist/server/envelope.js.map +1 -0
  49. package/package.json +7 -2
  50. package/src/client/App.tsx +16 -41
  51. package/src/client/components/Button.tsx +13 -22
  52. package/src/client/components/CopyButton.tsx +5 -12
  53. package/src/client/components/EnvBadge.tsx +30 -15
  54. package/src/client/components/MatrixGrid.tsx +108 -252
  55. package/src/client/components/Sidebar.tsx +123 -199
  56. package/src/client/components/StatusDot.tsx +10 -15
  57. package/src/client/components/SyncPanel.tsx +14 -62
  58. package/src/client/components/TopBar.tsx +11 -36
  59. package/src/client/index.html +1 -30
  60. package/src/client/main.tsx +1 -0
  61. package/src/client/primitives/Badge.test.tsx +47 -0
  62. package/src/client/primitives/Badge.tsx +64 -0
  63. package/src/client/primitives/Card.test.tsx +50 -0
  64. package/src/client/primitives/Card.tsx +85 -0
  65. package/src/client/primitives/Dialog.test.tsx +55 -0
  66. package/src/client/primitives/Dialog.tsx +96 -0
  67. package/src/client/primitives/EmptyState.test.tsx +25 -0
  68. package/src/client/primitives/EmptyState.tsx +38 -0
  69. package/src/client/primitives/Field.test.tsx +46 -0
  70. package/src/client/primitives/Field.tsx +95 -0
  71. package/src/client/primitives/Input.tsx +26 -0
  72. package/src/client/primitives/Stat.test.tsx +32 -0
  73. package/src/client/primitives/Stat.tsx +52 -0
  74. package/src/client/primitives/Table.test.tsx +58 -0
  75. package/src/client/primitives/Table.tsx +113 -0
  76. package/src/client/primitives/Tabs.test.tsx +44 -0
  77. package/src/client/primitives/Tabs.tsx +100 -0
  78. package/src/client/primitives/Toast.test.tsx +77 -0
  79. package/src/client/primitives/Toast.tsx +89 -0
  80. package/src/client/primitives/Toolbar.test.tsx +50 -0
  81. package/src/client/primitives/Toolbar.tsx +86 -0
  82. package/src/client/primitives/index.ts +43 -0
  83. package/src/client/public/clef.svg +2 -0
  84. package/src/client/screens/BackendScreen.tsx +104 -363
  85. package/src/client/screens/DiffView.tsx +187 -378
  86. package/src/client/screens/EnvelopeScreen.test.tsx +542 -0
  87. package/src/client/screens/EnvelopeScreen.tsx +948 -0
  88. package/src/client/screens/GitLogView.tsx +48 -106
  89. package/src/client/screens/ImportScreen.tsx +105 -308
  90. package/src/client/screens/LintView.tsx +184 -379
  91. package/src/client/screens/ManifestScreen.tsx +283 -445
  92. package/src/client/screens/MatrixView.tsx +75 -91
  93. package/src/client/screens/NamespaceEditor.tsx +234 -609
  94. package/src/client/screens/PolicyView.tsx +183 -453
  95. package/src/client/screens/RecipientsScreen.tsx +71 -350
  96. package/src/client/screens/ResetScreen.tsx +67 -237
  97. package/src/client/screens/ScanScreen.tsx +85 -249
  98. package/src/client/screens/SchemaEditor.test.tsx +237 -0
  99. package/src/client/screens/SchemaEditor.tsx +435 -0
  100. package/src/client/screens/ServiceIdentitiesScreen.tsx +251 -788
  101. package/src/client/styles.css +77 -0
  102. package/src/client/theme.ts +27 -48
  103. package/dist/client/assets/index-Db6WgHgY.js +0 -38
@@ -0,0 +1,948 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { apiFetch } from "../api";
3
+ import { Toolbar } from "../primitives";
4
+ import { Button } from "../components/Button";
5
+ import { CopyButton } from "../components/CopyButton";
6
+ import type { DecryptResult, InspectResult, SignatureStatus, VerifyResult } from "@clef-sh/core";
7
+
8
+ // Mirrors the server-side shape from packages/ui/src/server/envelope.ts.
9
+ // We inline the TS type here instead of importing because the server file
10
+ // is not an exported package entry, and the shape is small enough that a
11
+ // duplicated interface is clearer than a cross-package import.
12
+ interface EnvelopeConfig {
13
+ ageIdentity: {
14
+ configured: boolean;
15
+ source: "CLEF_AGE_KEY_FILE" | "CLEF_AGE_KEY" | null;
16
+ path: string | null;
17
+ };
18
+ aws: { hasCredentials: boolean; profile: string | null };
19
+ }
20
+
21
+ // Must match `formatRevealWarning` in @clef-sh/core/envelope-debug/warnings.
22
+ // We re-format client-side instead of calling the core helper because the
23
+ // warning is UI chrome, not a data contract — the test pins the literal.
24
+ function revealWarningText(singleKey?: string): string {
25
+ if (singleKey) {
26
+ return `value for key "${singleKey}" will be printed in this window until the reveal timer expires`;
27
+ }
28
+ return "all decrypted values will be printed in this window until the reveal timer expires";
29
+ }
30
+
31
+ // Short on purpose: the envelope debugger is for momentary peeks during
32
+ // triage (paste artifact, glance at value, move on), not editing — there's
33
+ // no workflow that needs the value visible for minutes at a time, and a
34
+ // long visible window is the bigger risk on a shared screen.
35
+ const REVEAL_TIMEOUT_MS = 15 * 1000;
36
+
37
+ const TEXTAREA_BASE =
38
+ "w-full bg-ink-950 text-bone border border-edge-strong rounded-md outline-none font-mono focus-visible:border-gold-500";
39
+
40
+ export function EnvelopeScreen() {
41
+ // Paste + inspect
42
+ const [rawJson, setRawJson] = useState("");
43
+ const [loaded, setLoaded] = useState(false);
44
+ const [loading, setLoading] = useState(false);
45
+ const [inspect, setInspect] = useState<InspectResult | null>(null);
46
+
47
+ // Verify
48
+ const [signerKey, setSignerKey] = useState("");
49
+ const [verifyLoading, setVerifyLoading] = useState(false);
50
+ const [verify, setVerify] = useState<VerifyResult | null>(null);
51
+
52
+ // Decrypt
53
+ const [decrypt, setDecrypt] = useState<DecryptResult | null>(null);
54
+ const [decryptLoading, setDecryptLoading] = useState(false);
55
+ const [revealedKeys, setRevealedKeys] = useState<Record<string, string>>({});
56
+ const [revealDeadline, setRevealDeadline] = useState<number | null>(null);
57
+ const [now, setNow] = useState<number>(() => Date.now());
58
+ const revealTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
59
+
60
+ // Config
61
+ const [config, setConfig] = useState<EnvelopeConfig | null>(null);
62
+
63
+ // Bring up the server-side config on mount so the Decrypt card knows what
64
+ // identity is available before the user even pastes.
65
+ useEffect(() => {
66
+ apiFetch("/api/envelope/config")
67
+ .then((r) => (r.ok ? r.json() : null))
68
+ .then((data) => setConfig(data))
69
+ .catch(() => setConfig(null));
70
+ }, []);
71
+
72
+ // Drive the countdown banner. Interval only runs while a reveal is active.
73
+ useEffect(() => {
74
+ if (!revealDeadline) return;
75
+ const id = setInterval(() => setNow(Date.now()), 1000);
76
+ return () => clearInterval(id);
77
+ }, [revealDeadline]);
78
+
79
+ // Clean up the auto-clear timer on unmount.
80
+ useEffect(() => () => clearTimeout(revealTimeoutRef.current), []);
81
+
82
+ const scheduleAutoClear = useCallback(() => {
83
+ clearTimeout(revealTimeoutRef.current);
84
+ const deadline = Date.now() + REVEAL_TIMEOUT_MS;
85
+ setRevealDeadline(deadline);
86
+ setNow(Date.now());
87
+ revealTimeoutRef.current = setTimeout(() => {
88
+ setRevealedKeys({});
89
+ setRevealDeadline(null);
90
+ }, REVEAL_TIMEOUT_MS);
91
+ }, []);
92
+
93
+ const resetEnvelopeState = useCallback(() => {
94
+ clearTimeout(revealTimeoutRef.current);
95
+ setInspect(null);
96
+ setVerify(null);
97
+ setDecrypt(null);
98
+ setRevealedKeys({});
99
+ setRevealDeadline(null);
100
+ setSignerKey("");
101
+ }, []);
102
+
103
+ const handleLoad = useCallback(async () => {
104
+ if (!rawJson.trim()) return;
105
+ setLoading(true);
106
+ resetEnvelopeState();
107
+ try {
108
+ const res = await apiFetch("/api/envelope/inspect", {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify({ raw: rawJson }),
112
+ });
113
+ if (!res.ok) {
114
+ setInspect(null);
115
+ setLoaded(false);
116
+ return;
117
+ }
118
+ const data = (await res.json()) as InspectResult;
119
+ setInspect(data);
120
+ setLoaded(!data.error);
121
+ } catch {
122
+ setInspect(null);
123
+ setLoaded(false);
124
+ } finally {
125
+ setLoading(false);
126
+ }
127
+ }, [rawJson, resetEnvelopeState]);
128
+
129
+ const handleVerify = useCallback(async () => {
130
+ if (!loaded) return;
131
+ setVerifyLoading(true);
132
+ try {
133
+ const res = await apiFetch("/api/envelope/verify", {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({ raw: rawJson, signerKey: signerKey || undefined }),
137
+ });
138
+ const data = (await res.json()) as VerifyResult;
139
+ setVerify(data);
140
+ } catch {
141
+ setVerify(null);
142
+ } finally {
143
+ setVerifyLoading(false);
144
+ }
145
+ }, [loaded, rawJson, signerKey]);
146
+
147
+ const handleDecryptKeys = useCallback(async () => {
148
+ if (!loaded) return;
149
+ setDecryptLoading(true);
150
+ try {
151
+ const res = await apiFetch("/api/envelope/decrypt", {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify({ raw: rawJson }),
155
+ });
156
+ const data = (await res.json()) as DecryptResult;
157
+ setDecrypt(data);
158
+ setRevealedKeys({});
159
+ setRevealDeadline(null);
160
+ } catch {
161
+ setDecrypt(null);
162
+ } finally {
163
+ setDecryptLoading(false);
164
+ }
165
+ }, [loaded, rawJson]);
166
+
167
+ const handleRevealAll = useCallback(async () => {
168
+ if (!loaded) return;
169
+ setDecryptLoading(true);
170
+ try {
171
+ const res = await apiFetch("/api/envelope/decrypt", {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({ raw: rawJson, reveal: true }),
175
+ });
176
+ const data = (await res.json()) as DecryptResult;
177
+ setDecrypt(data);
178
+ if (data.values) {
179
+ setRevealedKeys(data.values);
180
+ scheduleAutoClear();
181
+ }
182
+ } catch {
183
+ // Keep previous state — network failure shouldn't clear prior keys list.
184
+ } finally {
185
+ setDecryptLoading(false);
186
+ }
187
+ }, [loaded, rawJson, scheduleAutoClear]);
188
+
189
+ const handleRevealOne = useCallback(
190
+ async (key: string) => {
191
+ if (!loaded) return;
192
+ try {
193
+ const res = await apiFetch("/api/envelope/decrypt", {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify({ raw: rawJson, key }),
197
+ });
198
+ const data = (await res.json()) as DecryptResult;
199
+ if (!data.error && data.values && key in data.values) {
200
+ setRevealedKeys((prev) => ({ ...prev, [key]: data.values![key] }));
201
+ scheduleAutoClear();
202
+ }
203
+ } catch {
204
+ // Swallow — row stays hidden on failure.
205
+ }
206
+ },
207
+ [loaded, rawJson, scheduleAutoClear],
208
+ );
209
+
210
+ const handleHideOne = useCallback((key: string) => {
211
+ setRevealedKeys((prev) => {
212
+ const next = { ...prev };
213
+ delete next[key];
214
+ return next;
215
+ });
216
+ }, []);
217
+
218
+ const handleExportJson = useCallback(() => {
219
+ // Client-side download — server never touches the filesystem for this.
220
+ const blob = new Blob([JSON.stringify(revealedKeys, null, 2)], {
221
+ type: "application/json",
222
+ });
223
+ const url = URL.createObjectURL(blob);
224
+ const a = document.createElement("a");
225
+ a.href = url;
226
+ a.download = "envelope-revealed.json";
227
+ a.click();
228
+ URL.revokeObjectURL(url);
229
+ }, [revealedKeys]);
230
+
231
+ const anyRevealed = Object.keys(revealedKeys).length > 0;
232
+ const singleKeyRevealed =
233
+ Object.keys(revealedKeys).length === 1 ? Object.keys(revealedKeys)[0] : undefined;
234
+
235
+ const countdownMs = revealDeadline ? Math.max(0, revealDeadline - now) : 0;
236
+ const countdown = useMemo(() => formatCountdown(countdownMs), [countdownMs]);
237
+
238
+ const rawSnapshot = useMemo(() => {
239
+ const parts: Record<string, unknown> = {};
240
+ if (inspect) parts.inspect = inspect;
241
+ if (verify) parts.verify = verify;
242
+ if (decrypt) parts.decrypt = decrypt;
243
+ return Object.keys(parts).length > 0 ? JSON.stringify(parts, null, 2) : null;
244
+ }, [inspect, verify, decrypt]);
245
+
246
+ const [showRawJson, setShowRawJson] = useState(false);
247
+
248
+ return (
249
+ <div className="flex flex-1 flex-col overflow-hidden">
250
+ <Toolbar>
251
+ <div>
252
+ <Toolbar.Title>Envelope Debugger</Toolbar.Title>
253
+ <Toolbar.Subtitle>paste a packed artifact — inspect, verify, decrypt</Toolbar.Subtitle>
254
+ </div>
255
+ {rawSnapshot ? (
256
+ <Toolbar.Actions>
257
+ <Button onClick={() => setShowRawJson((s) => !s)}>
258
+ {showRawJson ? "Hide raw JSON" : "View raw JSON"}
259
+ </Button>
260
+ </Toolbar.Actions>
261
+ ) : null}
262
+ </Toolbar>
263
+
264
+ <div
265
+ className="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-3.5"
266
+ data-testid="envelope-screen"
267
+ >
268
+ <PasteArea
269
+ rawJson={rawJson}
270
+ setRawJson={setRawJson}
271
+ onLoad={handleLoad}
272
+ loading={loading}
273
+ />
274
+
275
+ {inspect && <InspectCard result={inspect} />}
276
+
277
+ {loaded && inspect && !inspect.error && (
278
+ <VerifyCard
279
+ signerKey={signerKey}
280
+ setSignerKey={setSignerKey}
281
+ onVerify={handleVerify}
282
+ loading={verifyLoading}
283
+ result={verify}
284
+ signaturePresent={inspect.signature.present}
285
+ />
286
+ )}
287
+
288
+ {loaded && inspect && !inspect.error && (
289
+ <DecryptCard
290
+ config={config}
291
+ result={decrypt}
292
+ loading={decryptLoading}
293
+ onDecryptKeys={handleDecryptKeys}
294
+ onRevealAll={handleRevealAll}
295
+ onRevealOne={handleRevealOne}
296
+ onHideOne={handleHideOne}
297
+ onExportJson={handleExportJson}
298
+ revealedKeys={revealedKeys}
299
+ anyRevealed={anyRevealed}
300
+ singleKeyRevealed={singleKeyRevealed}
301
+ countdown={countdown}
302
+ />
303
+ )}
304
+
305
+ {showRawJson && rawSnapshot && (
306
+ <pre
307
+ data-testid="raw-json"
308
+ className="font-mono text-[11px] bg-ink-850 border border-edge rounded-lg p-3.5 text-ash max-h-[320px] overflow-y-auto"
309
+ >
310
+ {rawSnapshot}
311
+ </pre>
312
+ )}
313
+ </div>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ // ──────────────────────────────────────────────────────────────────────
319
+ // PasteArea
320
+ // ──────────────────────────────────────────────────────────────────────
321
+
322
+ interface PasteAreaProps {
323
+ rawJson: string;
324
+ setRawJson: (s: string) => void;
325
+ onLoad: () => void;
326
+ loading: boolean;
327
+ }
328
+
329
+ function PasteArea({ rawJson, setRawJson, onLoad, loading }: PasteAreaProps) {
330
+ // Client-side shape validation — cheap feedback before the round-trip.
331
+ const parseState = useMemo(() => {
332
+ if (!rawJson.trim()) return { state: "empty" as const };
333
+ try {
334
+ JSON.parse(rawJson);
335
+ const bytes = new Blob([rawJson]).size;
336
+ return { state: "valid" as const, bytes };
337
+ } catch {
338
+ return { state: "invalid" as const };
339
+ }
340
+ }, [rawJson]);
341
+
342
+ const statusColor =
343
+ parseState.state === "invalid"
344
+ ? "text-stop-500"
345
+ : parseState.state === "valid"
346
+ ? "text-go-500"
347
+ : "text-ash";
348
+
349
+ return (
350
+ <Card title="Paste" subtitle="paste a packed envelope JSON">
351
+ <textarea
352
+ data-testid="envelope-paste-textarea"
353
+ value={rawJson}
354
+ onChange={(e) => setRawJson(e.target.value)}
355
+ placeholder={'{ "version": 1, "identity": "...", ... }'}
356
+ spellCheck={false}
357
+ className={`${TEXTAREA_BASE} min-h-[120px] max-h-[280px] resize-y text-[12px] p-2.5`}
358
+ />
359
+ <div className="flex items-center justify-between mt-2.5">
360
+ <span className={`font-mono text-[11px] ${statusColor}`} data-testid="paste-status">
361
+ {parseState.state === "valid"
362
+ ? `✓ valid (${formatBytes(parseState.bytes)})`
363
+ : parseState.state === "invalid"
364
+ ? "✕ invalid JSON"
365
+ : "paste to begin"}
366
+ </span>
367
+ <Button
368
+ variant="primary"
369
+ onClick={onLoad}
370
+ disabled={parseState.state !== "valid" || loading}
371
+ >
372
+ {loading ? "Loading…" : "Load"}
373
+ </Button>
374
+ </div>
375
+ </Card>
376
+ );
377
+ }
378
+
379
+ // ──────────────────────────────────────────────────────────────────────
380
+ // InspectCard
381
+ // ──────────────────────────────────────────────────────────────────────
382
+
383
+ interface InspectCardProps {
384
+ result: InspectResult;
385
+ }
386
+
387
+ function InspectCard({ result }: InspectCardProps) {
388
+ if (result.error) {
389
+ return (
390
+ <Card title="Inspect" tone="error">
391
+ <ErrorRow code={result.error.code} message={result.error.message} />
392
+ </Card>
393
+ );
394
+ }
395
+
396
+ // The type union admits nulls for error cases. We've already early-returned
397
+ // above on result.error, so the rest of the fields are present in practice
398
+ // — null coalesce at each site to keep TS happy rather than assert.
399
+ const hash = result.ciphertextHash ?? "";
400
+ const envelope = result.envelope;
401
+ const rows: [string, React.ReactNode][] = [
402
+ ["version", <Mono key="v">{result.version === null ? "?" : String(result.version)}</Mono>],
403
+ ["identity", <Mono key="i">{result.identity ?? "?"}</Mono>],
404
+ ["environment", <Mono key="e">{result.environment ?? "?"}</Mono>],
405
+ [
406
+ "packedAt",
407
+ <Mono key="p">
408
+ {result.packedAt ?? "?"}
409
+ {result.packedAtAgeMs !== null ? ` (${formatAge(result.packedAtAgeMs)})` : ""}
410
+ </Mono>,
411
+ ],
412
+ ["revision", <Mono key="r">{result.revision ?? "?"}</Mono>],
413
+ [
414
+ "ciphertextHash",
415
+ <span key="ch" className="flex items-center gap-2">
416
+ <Mono>{hash ? shortHash(hash) : "?"}</Mono>
417
+ <StatusPill
418
+ tone={
419
+ result.ciphertextHashVerified === true
420
+ ? "ok"
421
+ : result.ciphertextHashVerified === false
422
+ ? "fail"
423
+ : "muted"
424
+ }
425
+ label={
426
+ result.ciphertextHashVerified === true
427
+ ? "verified"
428
+ : result.ciphertextHashVerified === false
429
+ ? "MISMATCH"
430
+ : "not checked"
431
+ }
432
+ />
433
+ {hash && <CopyButton text={hash} />}
434
+ </span>,
435
+ ],
436
+ [
437
+ "ciphertext bytes",
438
+ <Mono key="cb">
439
+ {result.ciphertextBytes === null ? "?" : String(result.ciphertextBytes)}
440
+ </Mono>,
441
+ ],
442
+ [
443
+ "envelope",
444
+ <Mono key="env">
445
+ {envelope ? envelope.provider : "?"}
446
+ {envelope && envelope.kms ? ` · ${envelope.kms.keyId}` : ""}
447
+ </Mono>,
448
+ ],
449
+ [
450
+ "signature",
451
+ <span key="sig" className="flex items-center gap-2">
452
+ {result.signature.present ? (
453
+ <>
454
+ <Mono>{result.signature.algorithm ?? "unknown"}</Mono>
455
+ <StatusPill tone="muted" label="run verify to check" />
456
+ </>
457
+ ) : (
458
+ <StatusPill tone="muted" label="absent" />
459
+ )}
460
+ </span>,
461
+ ],
462
+ [
463
+ "expiry",
464
+ result.expiresAt ? (
465
+ <span key="ex" className="flex items-center gap-2">
466
+ <Mono>{result.expiresAt}</Mono>
467
+ <StatusPill
468
+ tone={result.expired ? "fail" : "ok"}
469
+ label={result.expired ? "EXPIRED" : "ok"}
470
+ />
471
+ </span>
472
+ ) : (
473
+ <Mono key="ex">none</Mono>
474
+ ),
475
+ ],
476
+ [
477
+ "revocation",
478
+ result.revoked ? (
479
+ <StatusPill key="rv" tone="fail" label={`REVOKED at ${result.revokedAt ?? "?"}`} />
480
+ ) : (
481
+ <Mono key="rv">none</Mono>
482
+ ),
483
+ ],
484
+ ];
485
+
486
+ return (
487
+ <Card title="Inspect" subtitle="auto-populates from the pasted JSON">
488
+ <div className="flex flex-col gap-1.5">
489
+ {rows.map(([label, value]) => (
490
+ <KeyValueRow key={label} label={label} value={value} />
491
+ ))}
492
+ </div>
493
+ </Card>
494
+ );
495
+ }
496
+
497
+ // ──────────────────────────────────────────────────────────────────────
498
+ // VerifyCard
499
+ // ──────────────────────────────────────────────────────────────────────
500
+
501
+ interface VerifyCardProps {
502
+ signerKey: string;
503
+ setSignerKey: (s: string) => void;
504
+ onVerify: () => void;
505
+ loading: boolean;
506
+ result: VerifyResult | null;
507
+ signaturePresent: boolean;
508
+ }
509
+
510
+ function VerifyCard({
511
+ signerKey,
512
+ setSignerKey,
513
+ onVerify,
514
+ loading,
515
+ result,
516
+ signaturePresent,
517
+ }: VerifyCardProps) {
518
+ const subtitle = signaturePresent
519
+ ? "paste the signer public key (PEM or base64 DER SPKI) to verify the signature"
520
+ : "no signature on this artifact — verify only checks hash / expiry / revocation";
521
+
522
+ const overallClasses =
523
+ result?.overall === "pass"
524
+ ? "bg-go-500/15 text-go-500"
525
+ : result?.overall === "fail"
526
+ ? "bg-stop-500/10 text-stop-500"
527
+ : "bg-edge text-ash";
528
+
529
+ return (
530
+ <Card title="Verify" subtitle={subtitle}>
531
+ {signaturePresent && (
532
+ <textarea
533
+ data-testid="envelope-signer-key"
534
+ value={signerKey}
535
+ onChange={(e) => setSignerKey(e.target.value)}
536
+ placeholder={"-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"}
537
+ spellCheck={false}
538
+ className={`${TEXTAREA_BASE} min-h-[72px] max-h-[160px] resize-y text-[11px] p-2 mb-2.5`}
539
+ />
540
+ )}
541
+ <div className="flex justify-end mb-2.5">
542
+ <Button variant="primary" onClick={onVerify} disabled={loading}>
543
+ {loading ? "Verifying…" : "Run verify"}
544
+ </Button>
545
+ </div>
546
+ {result?.error && <ErrorRow code={result.error.code} message={result.error.message} />}
547
+ {result && !result.error && (
548
+ <div className="flex flex-col gap-1.5">
549
+ <CheckRow
550
+ label="ciphertext hash"
551
+ status={result.checks.hash.status === "ok" ? "pass" : "fail"}
552
+ detail={result.checks.hash.status}
553
+ />
554
+ <CheckRow
555
+ label="signature"
556
+ status={mapSignatureStatus(result.checks.signature.status)}
557
+ detail={`${result.checks.signature.status}${
558
+ result.checks.signature.algorithm ? ` (${result.checks.signature.algorithm})` : ""
559
+ }`}
560
+ />
561
+ <CheckRow
562
+ label="expiry"
563
+ status={
564
+ result.checks.expiry.status === "expired"
565
+ ? "fail"
566
+ : result.checks.expiry.status === "absent"
567
+ ? "muted"
568
+ : "pass"
569
+ }
570
+ detail={result.checks.expiry.expiresAt ?? "no expiry"}
571
+ />
572
+ <CheckRow
573
+ label="revocation"
574
+ status={result.checks.revocation.status === "revoked" ? "fail" : "muted"}
575
+ detail={result.checks.revocation.revokedAt ?? "not revoked"}
576
+ />
577
+ <div
578
+ className={`mt-1.5 px-3 py-2 rounded-md font-sans text-[12px] font-bold tracking-[0.05em] ${overallClasses}`}
579
+ data-testid="verify-overall"
580
+ >
581
+ OVERALL: {result.overall.toUpperCase()}
582
+ </div>
583
+ </div>
584
+ )}
585
+ </Card>
586
+ );
587
+ }
588
+
589
+ // ──────────────────────────────────────────────────────────────────────
590
+ // DecryptCard
591
+ // ──────────────────────────────────────────────────────────────────────
592
+
593
+ interface DecryptCardProps {
594
+ config: EnvelopeConfig | null;
595
+ result: DecryptResult | null;
596
+ loading: boolean;
597
+ onDecryptKeys: () => void;
598
+ onRevealAll: () => void;
599
+ onRevealOne: (key: string) => void;
600
+ onHideOne: (key: string) => void;
601
+ onExportJson: () => void;
602
+ revealedKeys: Record<string, string>;
603
+ anyRevealed: boolean;
604
+ singleKeyRevealed: string | undefined;
605
+ countdown: string;
606
+ }
607
+
608
+ function DecryptCard({
609
+ config,
610
+ result,
611
+ loading,
612
+ onDecryptKeys,
613
+ onRevealAll,
614
+ onRevealOne,
615
+ onHideOne,
616
+ onExportJson,
617
+ revealedKeys,
618
+ anyRevealed,
619
+ singleKeyRevealed,
620
+ countdown,
621
+ }: DecryptCardProps) {
622
+ const identityConfigured = config?.ageIdentity.configured === true;
623
+ const identityInline = identityConfigured && config!.ageIdentity.source === "CLEF_AGE_KEY";
624
+ const identityLabel = identityConfigured
625
+ ? config!.ageIdentity.source === "CLEF_AGE_KEY_FILE"
626
+ ? `$CLEF_AGE_KEY_FILE · ${config!.ageIdentity.path ?? ""}`
627
+ : "$CLEF_AGE_KEY (inline)"
628
+ : "no identity configured on server — relaunch clef ui with CLEF_AGE_KEY_FILE set";
629
+
630
+ // Subtitle spells out the invariant that bit the operator the first time:
631
+ // the envelope must be encrypted for whichever key the server is using,
632
+ // which is usually a service identity's key, not the operator's own.
633
+ const subtitle = identityConfigured
634
+ ? `Decrypting with ${identityLabel}. The pasted envelope must be encrypted for this key — usually a service identity's age key, not your personal one.`
635
+ : identityLabel;
636
+
637
+ return (
638
+ <Card title="Decrypt" subtitle={subtitle}>
639
+ {identityInline && (
640
+ <div
641
+ data-testid="inline-key-warning"
642
+ className="mb-3 px-3 py-2 rounded-md bg-warn-500/15 border border-warn-500/40 text-warn-500 font-sans text-[12px] leading-relaxed"
643
+ >
644
+ {"⚠ "} This key was passed inline via <code>$CLEF_AGE_KEY</code>, which lands the secret
645
+ in your shell history (<code>~/.zsh_history</code>, <code>~/.bash_history</code>) and in{" "}
646
+ <code>ps aux</code> while the process runs. Prefer pointing at a file:{" "}
647
+ <code>CLEF_AGE_KEY_FILE=/path/to/key clef ui</code>. Rotate the current key if it may
648
+ already have been captured.
649
+ </div>
650
+ )}
651
+ {result?.error && (
652
+ <ErrorRow
653
+ code={result.error.code}
654
+ message={result.error.message}
655
+ hint={decryptErrorHint(result.error, identityConfigured)}
656
+ />
657
+ )}
658
+
659
+ {!result && (
660
+ <div className="flex justify-end">
661
+ <Button
662
+ variant="primary"
663
+ onClick={onDecryptKeys}
664
+ disabled={loading || !identityConfigured}
665
+ data-testid="decrypt-keys"
666
+ >
667
+ {loading ? "Decrypting…" : "Decrypt (keys)"}
668
+ </Button>
669
+ </div>
670
+ )}
671
+
672
+ {result && !result.error && (
673
+ <>
674
+ {anyRevealed && (
675
+ <div
676
+ data-testid="reveal-banner"
677
+ className="mb-3 px-3 py-2.5 rounded-md bg-warn-500/15 border border-warn-500/40 text-warn-500 font-sans text-[12px] flex items-center justify-between gap-2.5"
678
+ >
679
+ <span>
680
+ {"⚠ "} {revealWarningText(singleKeyRevealed)}
681
+ </span>
682
+ <span className="font-mono text-[11px]" data-testid="reveal-countdown">
683
+ auto-clears in {countdown}
684
+ </span>
685
+ </div>
686
+ )}
687
+
688
+ <div className="flex flex-col gap-1.5">
689
+ {result.keys.map((k) => {
690
+ const revealed = Object.prototype.hasOwnProperty.call(revealedKeys, k);
691
+ return (
692
+ <div
693
+ key={k}
694
+ data-testid={`decrypt-row-${k}`}
695
+ className="flex items-center gap-2.5 px-2.5 py-2 bg-ink-950 border border-edge rounded-md"
696
+ >
697
+ <span className="font-mono text-[12px] text-bone basis-[200px] shrink-0 grow-0 overflow-hidden text-ellipsis">
698
+ {k}
699
+ </span>
700
+ <span
701
+ className={`font-mono text-[12px] flex-1 overflow-hidden text-ellipsis whitespace-nowrap ${
702
+ revealed ? "text-bone" : "text-ash-dim"
703
+ }`}
704
+ data-testid={`decrypt-value-${k}`}
705
+ >
706
+ {revealed ? revealedKeys[k] : "●".repeat(10)}
707
+ </span>
708
+ <button
709
+ data-testid={`reveal-toggle-${k}`}
710
+ onClick={() => (revealed ? onHideOne(k) : onRevealOne(k))}
711
+ className="bg-transparent border border-edge-strong rounded text-ash font-mono text-[11px] px-2 py-0.5 cursor-pointer"
712
+ >
713
+ {revealed ? "hide" : "reveal"}
714
+ </button>
715
+ {revealed && <CopyButton text={revealedKeys[k]} />}
716
+ </div>
717
+ );
718
+ })}
719
+ </div>
720
+
721
+ <div className="mt-3.5 flex justify-between gap-2">
722
+ <Button onClick={onDecryptKeys} disabled={loading}>
723
+ {"↻"} Re-fetch keys
724
+ </Button>
725
+ <div className="flex gap-2">
726
+ {anyRevealed && (
727
+ <Button onClick={onExportJson} data-testid="export-json">
728
+ Export JSON
729
+ </Button>
730
+ )}
731
+ <Button
732
+ variant="primary"
733
+ onClick={onRevealAll}
734
+ disabled={loading}
735
+ data-testid="reveal-all"
736
+ >
737
+ Reveal all
738
+ </Button>
739
+ </div>
740
+ </div>
741
+ </>
742
+ )}
743
+ </Card>
744
+ );
745
+ }
746
+
747
+ // ──────────────────────────────────────────────────────────────────────
748
+ // Shared primitives
749
+ // ──────────────────────────────────────────────────────────────────────
750
+
751
+ interface CardProps {
752
+ title: string;
753
+ subtitle?: string;
754
+ tone?: "default" | "error";
755
+ children: React.ReactNode;
756
+ }
757
+
758
+ function Card({ title, subtitle, tone = "default", children }: CardProps) {
759
+ const borderClasses = tone === "error" ? "border-stop-500/40" : "border-edge";
760
+ return (
761
+ <div
762
+ className={`bg-ink-850 border rounded-lg p-4 ${borderClasses}`}
763
+ data-testid={`envelope-card-${title.toLowerCase()}`}
764
+ >
765
+ <div className="mb-2.5">
766
+ <div className="font-sans text-[13px] font-bold text-bone">{title}</div>
767
+ {subtitle && <div className="font-mono text-[10px] text-ash mt-0.5">{subtitle}</div>}
768
+ </div>
769
+ {children}
770
+ </div>
771
+ );
772
+ }
773
+
774
+ function KeyValueRow({ label, value }: { label: string; value: React.ReactNode }) {
775
+ return (
776
+ <div className="flex items-center gap-2.5">
777
+ <span className="font-mono text-[11px] text-ash basis-[140px] shrink-0 grow-0">{label}</span>
778
+ <span className="flex-1 min-w-0 text-[12px]">{value}</span>
779
+ </div>
780
+ );
781
+ }
782
+
783
+ function CheckRow({
784
+ label,
785
+ status,
786
+ detail,
787
+ }: {
788
+ label: string;
789
+ status: "pass" | "fail" | "warn" | "muted";
790
+ detail: string;
791
+ }) {
792
+ const toneColor =
793
+ status === "pass"
794
+ ? "text-go-500"
795
+ : status === "fail"
796
+ ? "text-stop-500"
797
+ : status === "warn"
798
+ ? "text-warn-500"
799
+ : "text-ash";
800
+ const icon = status === "pass" ? "✓" : status === "fail" ? "✕" : "·";
801
+ return (
802
+ <div
803
+ className="flex items-center gap-2.5 px-2.5 py-1.5 bg-ink-950 border border-edge rounded-md"
804
+ data-testid={`verify-row-${label.replace(/\s+/g, "-")}`}
805
+ >
806
+ <span className={`font-mono font-bold w-3 ${toneColor}`}>{icon}</span>
807
+ <span className="font-sans text-[12px] text-bone basis-[140px] shrink-0 grow-0">{label}</span>
808
+ <span className="font-mono text-[11px] text-ash flex-1">{detail}</span>
809
+ </div>
810
+ );
811
+ }
812
+
813
+ function StatusPill({ tone, label }: { tone: "ok" | "fail" | "warn" | "muted"; label: string }) {
814
+ const toneClasses =
815
+ tone === "ok"
816
+ ? "text-go-500 bg-go-500/15 border-go-500/40"
817
+ : tone === "fail"
818
+ ? "text-stop-500 bg-stop-500/10 border-stop-500/40"
819
+ : tone === "warn"
820
+ ? "text-warn-500 bg-warn-500/15 border-warn-500/40"
821
+ : "text-ash bg-ash/10 border-ash/30";
822
+ return (
823
+ <span
824
+ className={`font-mono text-[10px] font-bold border rounded-sm px-1.5 py-px ${toneClasses}`}
825
+ >
826
+ {label}
827
+ </span>
828
+ );
829
+ }
830
+
831
+ interface ErrorHint {
832
+ title: string;
833
+ commands: string[];
834
+ }
835
+
836
+ function ErrorRow({ code, message, hint }: { code: string; message: string; hint?: ErrorHint }) {
837
+ return (
838
+ <div
839
+ role="alert"
840
+ data-testid="envelope-error"
841
+ className="px-3 py-2.5 bg-stop-500/10 border border-stop-500/40 rounded-md font-mono text-[11px] text-stop-500"
842
+ >
843
+ <div>
844
+ <strong>{code}</strong> {"—"} {message}
845
+ </div>
846
+ {hint && (
847
+ <div
848
+ data-testid="envelope-error-hint"
849
+ className="mt-2.5 pt-2.5 border-t border-stop-500/30 text-bone font-sans text-[12px] leading-relaxed"
850
+ >
851
+ <div className="mb-2">{hint.title}</div>
852
+ <pre className="m-0 px-2.5 py-2 bg-ink-950 border border-edge rounded text-gold-500 font-mono text-[11px] whitespace-pre-wrap break-all">
853
+ {hint.commands.join("\n")}
854
+ </pre>
855
+ </div>
856
+ )}
857
+ </div>
858
+ );
859
+ }
860
+
861
+ /**
862
+ * Pick a hint block for a decrypt error. Targets the two most common
863
+ * support questions:
864
+ * - "no key configured at all" (key_resolution_failed)
865
+ * - "server has a key but it's not the right one for this envelope"
866
+ * (decrypt_failed with an age-encryption "no identity matched" message)
867
+ *
868
+ * Detection of the second case is string-sniffing on the age library's
869
+ * error text — brittle if the library changes its wording, but the worst
870
+ * case is that the hint quietly doesn't render and the raw error shows.
871
+ */
872
+ function decryptErrorHint(
873
+ error: { code: string; message: string },
874
+ identityConfigured: boolean,
875
+ ): ErrorHint | undefined {
876
+ if (error.code === "key_resolution_failed") {
877
+ return {
878
+ title:
879
+ "No age key on the server. Stop this server (Ctrl-C) and relaunch clef ui pointing at a key file:",
880
+ commands: [
881
+ "CLEF_AGE_KEY_FILE=/path/to/your-key.txt clef ui",
882
+ "# avoid CLEF_AGE_KEY='AGE-SECRET-KEY-...' — the key ends up in shell history",
883
+ ],
884
+ };
885
+ }
886
+ if (error.code === "decrypt_failed" && /no identity matched/i.test(error.message)) {
887
+ return {
888
+ title: identityConfigured
889
+ ? "The server's age key isn't one of this envelope's recipients. This usually means the envelope was packed for a service identity — relaunch clef ui with that identity's key:"
890
+ : "This envelope's recipients don't include any key on the server. Relaunch clef ui with the matching age key:",
891
+ commands: [
892
+ "# find the service identity's private key and launch with it",
893
+ "CLEF_AGE_KEY_FILE=/path/to/service-identity.key clef ui",
894
+ ],
895
+ };
896
+ }
897
+ return undefined;
898
+ }
899
+
900
+ function Mono({ children }: { children: React.ReactNode }) {
901
+ return <span className="font-mono text-[12px] text-bone">{children}</span>;
902
+ }
903
+
904
+ // ──────────────────────────────────────────────────────────────────────
905
+ // Helpers
906
+ // ──────────────────────────────────────────────────────────────────────
907
+
908
+ function mapSignatureStatus(status: SignatureStatus): "pass" | "fail" | "warn" | "muted" {
909
+ switch (status) {
910
+ case "valid":
911
+ return "pass";
912
+ case "invalid":
913
+ return "fail";
914
+ case "not_verified":
915
+ return "warn";
916
+ default:
917
+ return "muted";
918
+ }
919
+ }
920
+
921
+ function formatAge(ms: number): string {
922
+ const seconds = Math.floor(ms / 1000);
923
+ if (seconds < 60) return `${seconds}s ago`;
924
+ const minutes = Math.floor(seconds / 60);
925
+ if (minutes < 60) return `${minutes}m ago`;
926
+ const hours = Math.floor(minutes / 60);
927
+ if (hours < 24) return `${hours}h ago`;
928
+ const days = Math.floor(hours / 24);
929
+ return `${days}d ago`;
930
+ }
931
+
932
+ function formatBytes(n: number): string {
933
+ if (n < 1024) return `${n} B`;
934
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
935
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
936
+ }
937
+
938
+ function shortHash(h: string): string {
939
+ if (h.length <= 16) return h;
940
+ return `${h.slice(0, 8)}…${h.slice(-8)}`;
941
+ }
942
+
943
+ function formatCountdown(ms: number): string {
944
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
945
+ const minutes = Math.floor(totalSeconds / 60);
946
+ const seconds = totalSeconds % 60;
947
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
948
+ }