@ifc-lite/viewer 1.23.0 → 1.25.0

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 (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -0,0 +1,259 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `AuditLogPanel` — local audit log viewer rendered inside the
7
+ * Extensions dock.
8
+ *
9
+ * Reads from the extension host's append-only audit log. Surfaces:
10
+ * - install / uninstall / update / enable / disable
11
+ * - activate / deactivate
12
+ * - capability grant / revoke
13
+ * - mutation summary / network fetch (when those land)
14
+ * - health events (unhealthy / killed)
15
+ *
16
+ * The log is local-only. The "Export" button writes a JSON snapshot
17
+ * the user can keep / share. Clearing is one-click; there's no
18
+ * cross-device sync to worry about.
19
+ *
20
+ * Spec: docs/architecture/ai-customization/02-security.md §12.
21
+ */
22
+
23
+ import { useEffect, useState } from 'react';
24
+ import { Download, Trash2, FileText, Filter, X } from 'lucide-react';
25
+ import type { AuditEvent, AuditEventKind } from '@ifc-lite/extensions';
26
+ import { Button } from '@/components/ui/button';
27
+ import { ScrollArea } from '@/components/ui/scroll-area';
28
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
29
+ import { toast } from '@/components/ui/toast';
30
+ import { HelpHint } from './HelpHint';
31
+
32
+ const KIND_LABELS: Record<AuditEventKind, string> = {
33
+ install: 'Install',
34
+ uninstall: 'Uninstall',
35
+ update: 'Update',
36
+ enable: 'Enable',
37
+ disable: 'Disable',
38
+ capability_grant: 'Granted',
39
+ capability_revoke: 'Revoked',
40
+ activate: 'Activate',
41
+ deactivate: 'Deactivate',
42
+ mutation_summary: 'Mutations',
43
+ network_fetch: 'Fetch',
44
+ unhealthy: 'Unhealthy',
45
+ killed: 'Killed',
46
+ };
47
+
48
+ const KIND_TONES: Record<AuditEventKind, string> = {
49
+ install: 'text-emerald-600 dark:text-emerald-400',
50
+ uninstall: 'text-rose-600 dark:text-rose-400',
51
+ update: 'text-sky-600 dark:text-sky-400',
52
+ enable: 'text-emerald-600 dark:text-emerald-400',
53
+ disable: 'text-muted-foreground',
54
+ capability_grant: 'text-amber-600 dark:text-amber-400',
55
+ capability_revoke: 'text-amber-600 dark:text-amber-400',
56
+ activate: 'text-muted-foreground',
57
+ deactivate: 'text-muted-foreground',
58
+ mutation_summary: 'text-sky-600 dark:text-sky-400',
59
+ network_fetch: 'text-purple-600 dark:text-purple-400',
60
+ unhealthy: 'text-amber-600 dark:text-amber-400',
61
+ killed: 'text-rose-600 dark:text-rose-400',
62
+ };
63
+
64
+ interface AuditLogPanelProps {
65
+ /** Show only events from this extension id. Omit for all. */
66
+ extensionId?: string;
67
+ /** When in a panel, the close button. */
68
+ onClose?: () => void;
69
+ }
70
+
71
+ export function AuditLogPanel({ extensionId, onClose }: AuditLogPanelProps) {
72
+ const host = useExtensionHost();
73
+ const [events, setEvents] = useState<AuditEvent[]>([]);
74
+ const [filter, setFilter] = useState<AuditEventKind | 'all'>('all');
75
+ // Per-extension filter applied on top of the props-level filter.
76
+ // The prop scopes the panel; this state is the user's runtime
77
+ // narrow-down ("show only events for this extension").
78
+ const [extensionFilter, setExtensionFilter] = useState<string | undefined>(extensionId);
79
+
80
+ useEffect(() => {
81
+ const scope = extensionFilter ?? extensionId;
82
+ setEvents(host.audit.list(scope ? { extensionId: scope } : {}));
83
+ const off = host.onChange(() => {
84
+ setEvents(host.audit.list(scope ? { extensionId: scope } : {}));
85
+ });
86
+ return off;
87
+ }, [host, extensionId, extensionFilter]);
88
+
89
+ const filtered = filter === 'all' ? events : events.filter((e) => e.kind === filter);
90
+
91
+ // Build the list of distinct extension ids present in the (unfiltered)
92
+ // events for the per-extension chip row.
93
+ const distinctExtensionIds = Array.from(
94
+ new Set(host.audit.list().map((e) => e.extensionId)),
95
+ ).sort();
96
+
97
+ const handleExport = () => {
98
+ const json = host.audit.exportJson();
99
+ const blob = new Blob([json], { type: 'application/json' });
100
+ const url = URL.createObjectURL(blob);
101
+ const a = document.createElement('a');
102
+ a.href = url;
103
+ a.download = `ifclite-audit-${new Date().toISOString().slice(0, 10)}.json`;
104
+ a.click();
105
+ URL.revokeObjectURL(url);
106
+ toast.success('Audit log exported.');
107
+ };
108
+
109
+ const handleClear = () => {
110
+ if (!confirm('Clear the audit log? This cannot be undone.')) return;
111
+ host.audit.clear();
112
+ // Wipe the IDB mirror too — otherwise reload resurrects what the
113
+ // user just asked to forget.
114
+ void host.clearPersistedAuditLog().catch((err) => {
115
+ console.warn('[AuditLogPanel] clear persisted audit failed:', err);
116
+ });
117
+ setEvents([]);
118
+ toast.success('Audit log cleared.');
119
+ };
120
+
121
+ return (
122
+ <div className="flex flex-col h-full">
123
+ <div className="flex items-center justify-between border-b px-4 py-3">
124
+ <div className="flex items-center gap-2">
125
+ <FileText className="h-4 w-4" />
126
+ <h2 className="text-sm font-semibold">Audit Log</h2>
127
+ <span className="text-[11px] text-muted-foreground">
128
+ {filtered.length} of {events.length} events
129
+ </span>
130
+ <HelpHint label="Audit log">
131
+ <p>
132
+ Append-only ledger of every extension lifecycle event:
133
+ install, update, enable, disable, activate, capability
134
+ grant/revoke, runtime failures.
135
+ </p>
136
+ <p>
137
+ Persists in IndexedDB across reloads. Filter by event
138
+ kind via the chips below; when multiple extensions are
139
+ installed, a second chip row scopes by extension id.
140
+ </p>
141
+ <p><strong>Export</strong> downloads a JSON snapshot.</p>
142
+ </HelpHint>
143
+ </div>
144
+ <div className="flex items-center gap-1">
145
+ <Button size="sm" variant="ghost" onClick={handleExport} aria-label="Export audit log">
146
+ <Download className="mr-1 h-3.5 w-3.5" />
147
+ Export
148
+ </Button>
149
+ <Button size="sm" variant="ghost" onClick={handleClear} aria-label="Clear audit log">
150
+ <Trash2 className="mr-1 h-3.5 w-3.5" />
151
+ Clear
152
+ </Button>
153
+ {onClose && (
154
+ <Button size="icon" variant="ghost" onClick={onClose} aria-label="Close audit log">
155
+ <X className="h-3.5 w-3.5" />
156
+ </Button>
157
+ )}
158
+ </div>
159
+ </div>
160
+
161
+ <div className="flex items-center gap-1 border-b px-4 py-2 overflow-x-auto">
162
+ <Filter className="h-3 w-3 text-muted-foreground shrink-0" />
163
+ <FilterChip label="All" active={filter === 'all'} onClick={() => setFilter('all')} />
164
+ {(Object.keys(KIND_LABELS) as AuditEventKind[]).map((k) => (
165
+ <FilterChip
166
+ key={k}
167
+ label={KIND_LABELS[k]}
168
+ active={filter === k}
169
+ onClick={() => setFilter(k)}
170
+ />
171
+ ))}
172
+ </div>
173
+
174
+ {/* Extension scope row — appears only when the panel was opened
175
+ un-scoped AND there's more than one extension in the log.
176
+ Lets the user narrow "show only events for this extension". */}
177
+ {!extensionId && distinctExtensionIds.length > 1 && (
178
+ <div className="flex items-center gap-1 border-b px-4 py-2 overflow-x-auto">
179
+ <span className="text-[10px] text-muted-foreground shrink-0">Extension:</span>
180
+ <FilterChip
181
+ label="All"
182
+ active={extensionFilter === undefined}
183
+ onClick={() => setExtensionFilter(undefined)}
184
+ />
185
+ {distinctExtensionIds.map((id) => (
186
+ <FilterChip
187
+ key={id}
188
+ label={id}
189
+ active={extensionFilter === id}
190
+ onClick={() => setExtensionFilter(id)}
191
+ />
192
+ ))}
193
+ </div>
194
+ )}
195
+
196
+ <ScrollArea className="flex-1">
197
+ {filtered.length === 0 ? (
198
+ <div className="px-6 py-12 text-center text-sm text-muted-foreground">
199
+ No events yet. Audit entries appear here when extensions are installed,
200
+ updated, enabled, disabled, or uninstalled.
201
+ </div>
202
+ ) : (
203
+ <ul className="divide-y">
204
+ {filtered.slice().reverse().map((event) => (
205
+ <li key={event.seq} className="flex items-start gap-3 px-4 py-2.5 text-xs">
206
+ <span className={`shrink-0 font-medium ${KIND_TONES[event.kind]}`}>
207
+ {KIND_LABELS[event.kind]}
208
+ </span>
209
+ <div className="flex-1 min-w-0">
210
+ <div className="font-mono text-[11px] break-all">{event.extensionId}</div>
211
+ <div className="text-[10px] text-muted-foreground">
212
+ {new Date(event.ts).toLocaleString()}
213
+ {event.version ? ` · v${event.version}` : ''}
214
+ {extraDetail(event)}
215
+ </div>
216
+ </div>
217
+ </li>
218
+ ))}
219
+ </ul>
220
+ )}
221
+ </ScrollArea>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
227
+ return (
228
+ <button
229
+ type="button"
230
+ onClick={onClick}
231
+ className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
232
+ active
233
+ ? 'bg-primary text-primary-foreground'
234
+ : 'bg-muted text-muted-foreground hover:bg-muted/70'
235
+ }`}
236
+ >
237
+ {label}
238
+ </button>
239
+ );
240
+ }
241
+
242
+ function extraDetail(event: AuditEvent): string {
243
+ switch (event.kind) {
244
+ case 'install':
245
+ case 'update':
246
+ return event.grantedCapabilities
247
+ ? ` · ${event.grantedCapabilities.length} capability ${event.grantedCapabilities.length === 1 ? 'grant' : 'grants'}`
248
+ : '';
249
+ case 'mutation_summary':
250
+ return ` · ${event.entityCount} entities`;
251
+ case 'network_fetch':
252
+ return ` · ${event.host} (${event.bytes} bytes)`;
253
+ case 'unhealthy':
254
+ case 'killed':
255
+ return ` · ${event.reason}`;
256
+ default:
257
+ return '';
258
+ }
259
+ }
@@ -0,0 +1,102 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `BundlePreview` — read-only viewer for the files inside a bundle.
7
+ *
8
+ * Surfaces every file in the install bundle so the user can audit the
9
+ * code before approving. Plays nicely with `CapabilityReview` — the
10
+ * review modal exposes a "Show source" tab that mounts this.
11
+ *
12
+ * Spec: docs/architecture/ai-customization/01-extension-model.md §6.
13
+ */
14
+
15
+ import { useMemo, useState } from 'react';
16
+ import { Copy } from 'lucide-react';
17
+ import type { Bundle, BundleFile } from '@ifc-lite/extensions';
18
+ import { Button } from '@/components/ui/button';
19
+ import { ScrollArea } from '@/components/ui/scroll-area';
20
+ import { toast } from '@/components/ui/toast';
21
+ import { cn } from '@/lib/utils';
22
+
23
+ const DECODER = new TextDecoder();
24
+
25
+ interface BundlePreviewProps {
26
+ bundle: Bundle;
27
+ }
28
+
29
+ export function BundlePreview({ bundle }: BundlePreviewProps) {
30
+ const paths = useMemo(() => Array.from(bundle.files.keys()).sort(), [bundle]);
31
+ const [selected, setSelected] = useState<string>(paths[0] ?? 'manifest.json');
32
+
33
+ const file = bundle.files.get(selected);
34
+ const text = useMemo(() => fileToText(file), [file]);
35
+
36
+ const handleCopy = async () => {
37
+ try {
38
+ await navigator.clipboard.writeText(text);
39
+ toast.success(`Copied ${selected} to clipboard`);
40
+ } catch (err) {
41
+ toast.error(`Copy failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <div className="flex h-[420px] gap-3">
47
+ {/* File list */}
48
+ <ul
49
+ className="w-48 shrink-0 overflow-y-auto rounded border bg-muted/30 text-[11px]"
50
+ role="listbox"
51
+ aria-label="Bundle files"
52
+ >
53
+ {paths.map((path) => (
54
+ <li key={path} role="option" aria-selected={selected === path}>
55
+ <button
56
+ type="button"
57
+ onClick={() => setSelected(path)}
58
+ aria-label={`View ${path}`}
59
+ className={cn(
60
+ 'w-full text-left px-2 py-1 font-mono break-all transition-colors',
61
+ selected === path ? 'bg-primary/15 text-primary' : 'hover:bg-muted',
62
+ )}
63
+ >
64
+ {path}
65
+ </button>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+
70
+ {/* Source */}
71
+ <div className="flex-1 min-w-0 flex flex-col rounded border">
72
+ <div className="flex items-center justify-between border-b px-2 py-1 bg-muted/30 text-[11px]">
73
+ <code className="font-mono">{selected}</code>
74
+ <Button
75
+ size="sm"
76
+ variant="ghost"
77
+ onClick={() => void handleCopy()}
78
+ aria-label="Copy file contents"
79
+ >
80
+ <Copy className="mr-1 h-3 w-3" />
81
+ Copy
82
+ </Button>
83
+ </div>
84
+ <ScrollArea className="flex-1">
85
+ <pre className="px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-all">
86
+ {text}
87
+ </pre>
88
+ </ScrollArea>
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ function fileToText(file: BundleFile | undefined): string {
95
+ if (!file) return '(file missing)';
96
+ if (file.text) return file.text;
97
+ try {
98
+ return DECODER.decode(file.bytes);
99
+ } catch {
100
+ return `(${file.bytes.byteLength} bytes — binary)`;
101
+ }
102
+ }
@@ -0,0 +1,333 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `CapabilityReview` — modal dialog the user must confirm before any
7
+ * extension is installed.
8
+ *
9
+ * Sources the capability list from the bundle's manifest, parses each
10
+ * capability, runs the risk classifier, and surfaces a per-row badge
11
+ * with a plain-English description. The user can:
12
+ *
13
+ * - Approve every capability (default).
14
+ * - Uncheck individual capabilities they don't want to grant.
15
+ * The host enforces these at runtime via the inner-ring check;
16
+ * extensions that need them fail visibly.
17
+ * - Cancel.
18
+ *
19
+ * For red-tier capabilities we require the user to type "approve" as
20
+ * a friction layer — matching the threat-model recommendation in
21
+ * `02-security.md §4`.
22
+ *
23
+ * The dialog is purely presentational: it returns a grant decision to
24
+ * the parent via `onApprove(grants)` / `onCancel()`. The parent is
25
+ * responsible for calling `host.installFromBytes(bytes, grants)`.
26
+ */
27
+
28
+ import { useMemo, useState } from 'react';
29
+ import { AlertTriangle, CheckCircle2, FileCode2, ShieldAlert, ShieldCheck, X, KeyRound, Unlock } from 'lucide-react';
30
+ import { BundlePreview } from './BundlePreview';
31
+ import {
32
+ computeRisks,
33
+ overallTier,
34
+ parseCapability,
35
+ type Capability,
36
+ type CapabilityRisk,
37
+ type RiskTier,
38
+ } from '@ifc-lite/extensions';
39
+ import { Button } from '@/components/ui/button';
40
+ import {
41
+ Dialog,
42
+ DialogContent,
43
+ DialogDescription,
44
+ DialogFooter,
45
+ DialogHeader,
46
+ DialogTitle,
47
+ } from '@/components/ui/dialog';
48
+ import { Input } from '@/components/ui/input';
49
+ import { ScrollArea } from '@/components/ui/scroll-area';
50
+ import { cn } from '@/lib/utils';
51
+ import type { ExtensionInstallSummary } from '@/services/extensions/host.js';
52
+
53
+ interface CapabilityReviewProps {
54
+ open: boolean;
55
+ summary: ExtensionInstallSummary;
56
+ /** When supplied, render the capability diff vs the previously-granted set. */
57
+ previousGrants?: readonly string[];
58
+ /** Optional previous version label (e.g. "v1.2.0") for the diff banner. */
59
+ previousVersion?: string;
60
+ onApprove(grants: string[]): void;
61
+ onCancel(): void;
62
+ }
63
+
64
+ interface CapabilityRow {
65
+ raw: string;
66
+ capability: Capability | null;
67
+ risk: CapabilityRisk | null;
68
+ }
69
+
70
+ const APPROVE_PHRASE = 'approve';
71
+
72
+ export function CapabilityReview({
73
+ open,
74
+ summary,
75
+ previousGrants,
76
+ previousVersion,
77
+ onApprove,
78
+ onCancel,
79
+ }: CapabilityReviewProps) {
80
+ const rows = useMemo<CapabilityRow[]>(() => {
81
+ return summary.capabilities.map((raw) => {
82
+ const parsed = parseCapability(raw);
83
+ if (!parsed.ok) return { raw, capability: null, risk: null };
84
+ const [risk] = computeRisks([parsed.value]);
85
+ return { raw, capability: parsed.value, risk };
86
+ });
87
+ }, [summary]);
88
+
89
+ const overall = useMemo<RiskTier>(() => {
90
+ return overallTier(rows.map((r) => r.risk).filter((r): r is CapabilityRisk => !!r));
91
+ }, [rows]);
92
+
93
+ /** Capability strings introduced since the previous install, if any. */
94
+ const newSinceUpgrade = useMemo<Set<string>>(() => {
95
+ if (!previousGrants) return new Set();
96
+ const prior = new Set(previousGrants);
97
+ return new Set(summary.capabilities.filter((c) => !prior.has(c)));
98
+ }, [previousGrants, summary.capabilities]);
99
+
100
+ /** Capability strings the new bundle no longer requests. */
101
+ const droppedSinceUpgrade = useMemo<string[]>(() => {
102
+ if (!previousGrants) return [];
103
+ const next = new Set(summary.capabilities);
104
+ return previousGrants.filter((c) => !next.has(c));
105
+ }, [previousGrants, summary.capabilities]);
106
+
107
+ const [granted, setGranted] = useState<Set<string>>(
108
+ () => new Set(summary.capabilities),
109
+ );
110
+ const [confirmText, setConfirmText] = useState('');
111
+ const [tab, setTab] = useState<'capabilities' | 'source'>('capabilities');
112
+
113
+ const needsConfirm = useMemo(() => {
114
+ for (const row of rows) {
115
+ if (row.risk?.tier === 'red' && granted.has(row.raw)) return true;
116
+ }
117
+ return false;
118
+ }, [rows, granted]);
119
+
120
+ const canApprove =
121
+ !needsConfirm || confirmText.trim().toLowerCase() === APPROVE_PHRASE;
122
+
123
+ const toggle = (raw: string, checked: boolean) => {
124
+ setGranted((prev) => {
125
+ const next = new Set(prev);
126
+ if (checked) next.add(raw);
127
+ else next.delete(raw);
128
+ return next;
129
+ });
130
+ };
131
+
132
+ return (
133
+ <Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
134
+ <DialogContent className="max-w-2xl">
135
+ <DialogHeader>
136
+ <div className="flex items-center gap-2">
137
+ <RiskIcon tier={overall} />
138
+ <DialogTitle>
139
+ Install <span className="font-mono text-base">{summary.id}</span> v{summary.version}?
140
+ </DialogTitle>
141
+ </div>
142
+ <DialogDescription>
143
+ Review the capabilities this extension is requesting. Uncheck any
144
+ you do not want to grant. Extensions that rely on a denied
145
+ capability will surface a clear error at runtime instead of running
146
+ silently with broader scope.
147
+ </DialogDescription>
148
+ </DialogHeader>
149
+
150
+ {summary.signed && summary.signature ? (
151
+ <div className="flex items-start gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-xs">
152
+ <KeyRound className="h-4 w-4 text-emerald-600 dark:text-emerald-400 mt-0.5 shrink-0" />
153
+ <div className="min-w-0">
154
+ <div className="font-medium text-emerald-700 dark:text-emerald-400">
155
+ Signature verified
156
+ </div>
157
+ <div className="text-muted-foreground mt-0.5">
158
+ Signed by <code className="font-mono text-[10px]" title={summary.signature.fingerprint}>
159
+ {summary.signature.fingerprint.slice(0, 23)}…
160
+ </code>
161
+ {' · '}
162
+ {new Date(summary.signature.signedAt).toLocaleString()}
163
+ </div>
164
+ </div>
165
+ </div>
166
+ ) : (
167
+ <div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
168
+ <Unlock className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
169
+ <div>
170
+ <div className="font-medium text-amber-700 dark:text-amber-400">
171
+ Unsigned bundle
172
+ </div>
173
+ <div className="text-muted-foreground mt-0.5">
174
+ This bundle has no signature. We cannot verify it came from a
175
+ specific publisher — install only if you trust the source.
176
+ </div>
177
+ </div>
178
+ </div>
179
+ )}
180
+
181
+ {previousGrants && (newSinceUpgrade.size > 0 || droppedSinceUpgrade.length > 0) && (
182
+ <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
183
+ <div className="font-medium text-amber-700 dark:text-amber-400">
184
+ Capability changes since {previousVersion ?? 'the previous version'}
185
+ </div>
186
+ {newSinceUpgrade.size > 0 && (
187
+ <div className="mt-1">
188
+ <span className="text-[10px] uppercase tracking-wide font-semibold text-amber-600">New:</span>{' '}
189
+ {[...newSinceUpgrade].map((c) => (
190
+ <code key={c} className="font-mono text-[10px] mr-1 bg-amber-500/20 rounded px-1 py-0.5">
191
+ {c}
192
+ </code>
193
+ ))}
194
+ </div>
195
+ )}
196
+ {droppedSinceUpgrade.length > 0 && (
197
+ <div className="mt-1">
198
+ <span className="text-[10px] uppercase tracking-wide font-semibold text-amber-600">Dropped:</span>{' '}
199
+ {droppedSinceUpgrade.map((c) => (
200
+ <code key={c} className="font-mono text-[10px] mr-1 line-through opacity-70">
201
+ {c}
202
+ </code>
203
+ ))}
204
+ </div>
205
+ )}
206
+ </div>
207
+ )}
208
+
209
+ <div className="flex items-center gap-1 border-b" role="tablist" aria-label="Bundle inspection mode">
210
+ <button
211
+ type="button"
212
+ onClick={() => setTab('capabilities')}
213
+ role="tab"
214
+ aria-selected={tab === 'capabilities'}
215
+ className={cn(
216
+ 'flex items-center gap-1 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors',
217
+ tab === 'capabilities'
218
+ ? 'border-primary text-primary'
219
+ : 'border-transparent text-muted-foreground hover:text-foreground',
220
+ )}
221
+ >
222
+ <ShieldCheck className="h-3.5 w-3.5" />
223
+ Capabilities
224
+ </button>
225
+ <button
226
+ type="button"
227
+ onClick={() => setTab('source')}
228
+ role="tab"
229
+ aria-selected={tab === 'source'}
230
+ className={cn(
231
+ 'flex items-center gap-1 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors',
232
+ tab === 'source'
233
+ ? 'border-primary text-primary'
234
+ : 'border-transparent text-muted-foreground hover:text-foreground',
235
+ )}
236
+ >
237
+ <FileCode2 className="h-3.5 w-3.5" />
238
+ Source
239
+ </button>
240
+ </div>
241
+
242
+ {tab === 'source' ? (
243
+ <BundlePreview bundle={summary.bundle} />
244
+ ) : (
245
+ <ScrollArea className="max-h-72 rounded-md border">
246
+ <ul className="divide-y">
247
+ {rows.length === 0 && (
248
+ <li className="px-4 py-3 text-sm text-muted-foreground">
249
+ This extension requests no capabilities — viewer-only chrome.
250
+ </li>
251
+ )}
252
+ {rows.map((row) => (
253
+ <li key={row.raw} className="flex items-start gap-3 px-4 py-3">
254
+ <input
255
+ type="checkbox"
256
+ className="mt-1"
257
+ checked={granted.has(row.raw)}
258
+ onChange={(e) => toggle(row.raw, e.target.checked)}
259
+ aria-label={`Grant capability ${row.raw}`}
260
+ />
261
+ <div className="flex-1 min-w-0">
262
+ <div className="flex items-center gap-2">
263
+ <code className="text-xs font-mono">{row.raw}</code>
264
+ <RiskBadge tier={row.risk?.tier ?? 'red'} />
265
+ </div>
266
+ <p className="mt-1 text-xs text-muted-foreground">
267
+ {row.risk?.description ?? 'Unknown capability — treated as high-risk.'}
268
+ </p>
269
+ </div>
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ </ScrollArea>
274
+ )}
275
+
276
+ {needsConfirm && tab === 'capabilities' && (
277
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm">
278
+ <div className="font-medium text-destructive flex items-center gap-2">
279
+ <ShieldAlert className="h-4 w-4" />
280
+ High-risk capability requested
281
+ </div>
282
+ <p className="mt-1 text-xs text-muted-foreground">
283
+ Type <code className="font-mono">{APPROVE_PHRASE}</code> below to confirm.
284
+ </p>
285
+ <Input
286
+ className="mt-2"
287
+ value={confirmText}
288
+ onChange={(e) => setConfirmText(e.target.value)}
289
+ placeholder="approve"
290
+ aria-label="Type approve to confirm"
291
+ autoFocus
292
+ />
293
+ </div>
294
+ )}
295
+
296
+ <DialogFooter>
297
+ <Button variant="ghost" onClick={onCancel}>
298
+ <X className="mr-1 h-4 w-4" />
299
+ Cancel
300
+ </Button>
301
+ <Button
302
+ disabled={!canApprove}
303
+ onClick={() => onApprove(Array.from(granted))}
304
+ >
305
+ <CheckCircle2 className="mr-1 h-4 w-4" />
306
+ Install
307
+ </Button>
308
+ </DialogFooter>
309
+ </DialogContent>
310
+ </Dialog>
311
+ );
312
+ }
313
+
314
+ function RiskIcon({ tier }: { tier: RiskTier }) {
315
+ if (tier === 'red') return <ShieldAlert className="h-5 w-5 text-destructive" />;
316
+ if (tier === 'yellow') return <AlertTriangle className="h-5 w-5 text-amber-500" />;
317
+ return <CheckCircle2 className="h-5 w-5 text-emerald-500" />;
318
+ }
319
+
320
+ function RiskBadge({ tier }: { tier: RiskTier }) {
321
+ return (
322
+ <span
323
+ className={cn(
324
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
325
+ tier === 'red' && 'bg-destructive/20 text-destructive',
326
+ tier === 'yellow' && 'bg-amber-500/20 text-amber-600 dark:text-amber-400',
327
+ tier === 'green' && 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-400',
328
+ )}
329
+ >
330
+ {tier}
331
+ </span>
332
+ );
333
+ }