@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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 (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,912 @@
1
+ import { useEffect } from "preact/hooks";
2
+ import { useSignal, useComputed } from "@preact/signals";
3
+ import type { SerializablePermission } from "../../sandbox.ts";
4
+ import { Button } from "../components/button.tsx";
5
+ import { ImagePosture } from "../components/image-posture.tsx";
6
+ import { PermissionRow } from "../components/permission-row.tsx";
7
+ import { PermissionSection, SectionLabel } from "../components/permission-section.tsx";
8
+ import { CubeIcon } from "../components/icons.tsx";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from "../components/dialog.tsx";
17
+ import { Checkbox } from "../components/checkbox.tsx";
18
+ import {
19
+ PERMISSION_META,
20
+ partitionHostKinds,
21
+ type HostPermissionKind,
22
+ type WorkerCapability,
23
+ type PermissionKind,
24
+ } from "../../home.ts";
25
+ import {
26
+ TabsForm,
27
+ type ConfigFieldTree,
28
+ type ConfigField,
29
+ type ConfigContainerNode,
30
+ } from "./tabs-form.tsx";
31
+ import { DeployTargetsPicker, type DeployTargets } from "./deploy-targets-picker.tsx";
32
+ import { descriptorsToGrants } from "../../grants-builder.ts";
33
+ import { permsInInactiveSections } from "./perms-inactive.ts";
34
+ import { findMissingRequired } from "./required-fields.ts";
35
+ import { InstallError, type InstallErrorShape } from "./install-error.tsx";
36
+ import {
37
+ InstallAdapterError,
38
+ type InstallAdapter,
39
+ type PreviewResult,
40
+ type SiloOption,
41
+ } from "./adapter.ts";
42
+
43
+ interface Worker {
44
+ id: string;
45
+ slug: string;
46
+ name: string | null;
47
+ capabilities: WorkerCapability[];
48
+ }
49
+
50
+ // Rough "how old" string for the image posture card.
51
+ function formatAge(iso: string | null): string {
52
+ if (!iso) return "unknown";
53
+ const then = new Date(iso).getTime();
54
+ if (Number.isNaN(then)) return "unknown";
55
+ const days = Math.floor((Date.now() - then) / 86_400_000);
56
+ if (days < 7) return days <= 1 ? "1 day" : `${days} days`;
57
+ const weeks = Math.floor(days / 7);
58
+ if (weeks < 9) return weeks === 1 ? "1 week" : `${weeks} weeks`;
59
+ const months = Math.floor(days / 30);
60
+ if (months < 24) return months === 1 ? "1 month" : `${months} months`;
61
+ const years = Math.floor(days / 365);
62
+ return years === 1 ? "1 year" : `${years} years`;
63
+ }
64
+
65
+ interface Props {
66
+ workers: Worker[];
67
+ plumbing: InstallAdapter;
68
+ /**
69
+ * Pre-fill the source on mount and run `preview` immediately, jumping
70
+ * straight to the summary step. The textarea isn't rendered when this
71
+ * is set — the panel assumes the host has authoritative access to the
72
+ * realm.ts on disk. Used by `realm-sdk dev`'s /install route.
73
+ */
74
+ initialSource?: string;
75
+ /**
76
+ * Upstream registry id (e.g. "realm/counter-fresh") forwarded to
77
+ * `plumbing.install` so the host can correlate the new app row with
78
+ * its store listing. Set by the host when `initialSource` came from a
79
+ * known listing.
80
+ */
81
+ sourceId?: string;
82
+ /**
83
+ * Skip the DeployTargetsPicker on the configure step. `deployTargets`
84
+ * stays at its `true` default so the install payload remains valid.
85
+ * Used in dev where there's exactly one local target.
86
+ */
87
+ hideDeployTargets?: boolean;
88
+ /**
89
+ * Render a secondary "Install as template" button next to the primary
90
+ * install button. A template install posts `role: "template"`, landing a
91
+ * parked row that never deploys — a `builder`-scoped app (e.g.
92
+ * Forge) stamps previews from it. Off by default, so the store-install and
93
+ * `realm-sdk dev` flows keep a single install button. Home's "from source"
94
+ * install page sets it.
95
+ */
96
+ showInstallAsTemplate?: boolean;
97
+ }
98
+
99
+ // The worker a single app starts pinned to: the first that
100
+ // advertises every required host kind. `null` when none qualifies — the
101
+ // picker then renders expanded and unpicked, because *why* nothing was
102
+ // chosen (each worker's missing kinds) is what the operator needs to read.
103
+ function autoPickWorker(workers: Worker[], requiredKinds: HostPermissionKind[]): string | null {
104
+ const capable = workers.find((w) => {
105
+ const caps = new Set(w.capabilities);
106
+ return requiredKinds.every((k) => caps.has(k));
107
+ });
108
+ return capable?.id ?? null;
109
+ }
110
+
111
+ function defaultConfigValues(schema: ConfigFieldTree): Record<string, unknown> {
112
+ const out: Record<string, unknown> = {};
113
+ for (const [name, field] of Object.entries(schema)) {
114
+ if (field.type === "permission") continue;
115
+ if (field.type === "tab" || field.type === "section") {
116
+ // Optional sections are `undefined` until the user toggles the
117
+ // feature on - that's the only way the sandbox learns the feature
118
+ // is active.
119
+ if (field.optional) continue;
120
+ out[name] = defaultConfigValues(field.fields);
121
+ } else if ("default" in field && field.default !== undefined) {
122
+ out[name] = field.default;
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function setPath(
129
+ obj: Record<string, unknown>,
130
+ path: string,
131
+ value: unknown,
132
+ ): Record<string, unknown> {
133
+ const segments = path.split(".");
134
+ const clone: Record<string, unknown> = { ...obj };
135
+ let cursor = clone;
136
+ for (let i = 0; i < segments.length - 1; i++) {
137
+ const seg = segments[i]!;
138
+ const next = cursor[seg];
139
+ const nextObj =
140
+ next && typeof next === "object" && !Array.isArray(next)
141
+ ? { ...(next as Record<string, unknown>) }
142
+ : {};
143
+ cursor[seg] = nextObj;
144
+ cursor = nextObj;
145
+ }
146
+ if (value === undefined) {
147
+ delete cursor[segments[segments.length - 1]!];
148
+ } else {
149
+ cursor[segments[segments.length - 1]!] = value;
150
+ }
151
+ return clone;
152
+ }
153
+
154
+ // "Don't show this again" for the install-as-template explainer: one key
155
+ // holding a level. 0 = never dismissed, 1 = base explainer, 2 = base + the
156
+ // "no persistent volumes" note; a higher level subsumes the lower, so a
157
+ // level-1 dismissal still surfaces the volumes note on the first
158
+ // volume-declaring template. Guarded so SSR / disabled storage always show.
159
+ const TEMPLATE_NOTICE_KEY = "realm.templateInstallNoticeDismissed";
160
+ function dismissedLevel(): number {
161
+ try {
162
+ if (typeof localStorage === "undefined") return 0;
163
+ return Number(localStorage.getItem(TEMPLATE_NOTICE_KEY)) || 0;
164
+ } catch {
165
+ return 0;
166
+ }
167
+ }
168
+ function setDismissedLevel(level: number): void {
169
+ try {
170
+ // Never downgrade — a higher dismissal already covers the lower notice.
171
+ if (typeof localStorage !== "undefined") {
172
+ localStorage.setItem(TEMPLATE_NOTICE_KEY, String(Math.max(level, dismissedLevel())));
173
+ }
174
+ } catch {
175
+ // Private mode / storage disabled — the notice just keeps showing.
176
+ }
177
+ }
178
+ // The dismissal level a template needs: a volume-declaring template shows the
179
+ // extra note (2); a volumeless one only the base explainer (1).
180
+ function templateNoticeLevel(hasVolumes: boolean): number {
181
+ return hasVolumes ? 2 : 1;
182
+ }
183
+ function shouldShowTemplateDialog(hasVolumes: boolean): boolean {
184
+ return dismissedLevel() < templateNoticeLevel(hasVolumes);
185
+ }
186
+
187
+ export default function InstallAppPanel({
188
+ workers,
189
+ plumbing,
190
+ initialSource,
191
+ sourceId,
192
+ hideDeployTargets,
193
+ showInstallAsTemplate,
194
+ }: Props) {
195
+ const step = useSignal<"discover" | "summary" | "configure">("discover");
196
+ const source = useSignal(initialSource ?? "");
197
+ const preview = useSignal<PreviewResult | null>(null);
198
+ const config = useSignal<Record<string, unknown>>({});
199
+ const enabled = useSignal<Set<string>>(new Set());
200
+ const grantValues = useSignal<Record<string, unknown>>({});
201
+ // Per-path: a `home.publicDomain` leaf whose add field still holds a
202
+ // hostname nobody turned into a row. Keyed rather than a flag because an
203
+ // app may declare two leaves.
204
+ const pendingDomains = useSignal<Record<string, boolean>>({});
205
+ const deployTargets = useSignal<DeployTargets | null>(null);
206
+ // Only loaded for apps that declare host.silo; every other install never
207
+ // asks the host for its Silos.
208
+ const silos = useSignal<SiloOption[]>([]);
209
+ const parsing = useSignal(false);
210
+ const installing = useSignal(false);
211
+ const error = useSignal<InstallErrorShape | null>(null);
212
+ // "Install as template" opens an explainer dialog (unless the operator
213
+ // dismissed it); the checkbox persists that dismissal to localStorage.
214
+ const templateDialogOpen = useSignal(false);
215
+ const dontShowAgain = useSignal(false);
216
+ // The host declares whether it does Silos by implementing the methods. A host
217
+ // without them gets no picker and no gate: `realm-sdk dev` has no Home to hold
218
+ // a Silo record, and its compiler already fails a silo app with an actionable
219
+ // message that a disabled button would replace with a dead end.
220
+ const hostHasSilos = typeof plumbing.listSilos === "function";
221
+
222
+ async function onParse() {
223
+ error.value = null;
224
+ if (!source.value.trim()) {
225
+ error.value = { kind: "message", message: "Paste a realm.ts source first." };
226
+ return;
227
+ }
228
+ parsing.value = true;
229
+ try {
230
+ const result = await plumbing.preview(source.value);
231
+ preview.value = result;
232
+ // Pre-fill from an existing install if the host surfaces one (`realm-sdk
233
+ // dev` does, via the on-disk realm-sdk.dev.ts), so a second visit to
234
+ // /install on the same app is an edit flow. Otherwise schema defaults.
235
+ config.value = result.currentConfig ?? defaultConfigValues(result.configFields);
236
+ // Default the deploy picker per app kind: "single" pre-selects the first
237
+ // worker that satisfies the app's required host kinds (null if none can);
238
+ // "system" has no choice to make and stays null, which is what the install
239
+ // payload omits. In dev (`hideDeployTargets`) there is no picker and the
240
+ // dev install handler ignores `deployed` either way.
241
+ deployTargets.value =
242
+ hideDeployTargets || result.kind === "system"
243
+ ? null
244
+ : autoPickWorker(
245
+ workers,
246
+ partitionHostKinds(result.permissionsFlat, result.configFields).required,
247
+ );
248
+ const initial = new Set<string>();
249
+ const initialGrantValues: Record<string, unknown> = {};
250
+ if (result.currentGrants) {
251
+ for (const [path, grant] of Object.entries(result.currentGrants)) {
252
+ initial.add(path);
253
+ // PeerGrant has no `data` field (peers are presence-only at
254
+ // grant time); skip those. For host grants, `data === true`
255
+ // is the presence-only flag (host.gpu/egress/management)
256
+ // and doesn't need a per-path override either.
257
+ if ("data" in grant && grant.data !== true && grant.data !== undefined) {
258
+ initialGrantValues[path] = grant.data;
259
+ }
260
+ }
261
+ } else {
262
+ // No existing fixture — start with every required permission
263
+ // toggled on; optional ones default off, admin can opt in from
264
+ // the configure step.
265
+ for (const [path, perm] of Object.entries(result.permissionsFlat)) {
266
+ if (!(perm.meta as { isOptional?: boolean }).isOptional) initial.add(path);
267
+ }
268
+ }
269
+ enabled.value = initial;
270
+ grantValues.value = initialGrantValues;
271
+ // Loaded before the step advances, so the configure step never renders a
272
+ // picker whose list is still in flight - an empty picker and a loading
273
+ // one look identical, and the difference is "create a duplicate Silo".
274
+ const listSilos = plumbing.listSilos?.bind(plumbing);
275
+ if (listSilos && Object.values(result.permissionsFlat).some((p) => p.kind === "host.silo")) {
276
+ try {
277
+ silos.value = await listSilos();
278
+ } catch (err) {
279
+ error.value = {
280
+ kind: "network",
281
+ message: `Couldn't load Silos: ${(err as Error).message}`,
282
+ };
283
+ }
284
+ }
285
+ step.value = "summary";
286
+ } catch (err) {
287
+ error.value =
288
+ err instanceof InstallAdapterError
289
+ ? err.shape
290
+ : { kind: "network", message: (err as Error).message };
291
+ } finally {
292
+ parsing.value = false;
293
+ }
294
+ }
295
+
296
+ async function onInstall(asTemplate: boolean) {
297
+ if (!preview.value) return;
298
+ error.value = null;
299
+ installing.value = true;
300
+ let installedId: string | null = null;
301
+ try {
302
+ // Drop any grants the admin left toggled on inside an
303
+ // optional section that's currently off - the feature isn't
304
+ // consumed so the grant shouldn't be stored.
305
+ const inactive = permsInInactiveSections(
306
+ preview.value.configFields,
307
+ config.value,
308
+ preview.value.permissionsFlat,
309
+ );
310
+ const effectiveEnabled = new Set<string>();
311
+ for (const p of enabled.value) {
312
+ if (!inactive.has(p)) effectiveEnabled.add(p);
313
+ }
314
+ const grants = descriptorsToGrants(preview.value.permissionsFlat, {
315
+ enabled: effectiveEnabled,
316
+ grantValues: grantValues.value,
317
+ });
318
+ // A single app has to name its worker; `missingDeployTarget` already
319
+ // disables the button, so this is the type narrowing. A system app
320
+ // sends nothing — the host derives its placement from the source.
321
+ const deployed = deployTargets.value;
322
+ if (missingDeployTarget.value) return;
323
+ const result = await plumbing.install({
324
+ source: source.value,
325
+ ...(deployed !== null ? { deployed } : {}),
326
+ config: config.value,
327
+ grants,
328
+ ...(sourceId ? { sourceId } : {}),
329
+ ...(asTemplate ? { role: "template" as const } : {}),
330
+ });
331
+ installedId = result.id;
332
+ } catch (err) {
333
+ error.value =
334
+ err instanceof InstallAdapterError
335
+ ? err.shape
336
+ : { kind: "network", message: (err as Error).message };
337
+ return;
338
+ } finally {
339
+ installing.value = false;
340
+ }
341
+ // Post-install side-effect runs only after `install` resolved.
342
+ // An exception thrown here must NOT masquerade as install
343
+ // failure — the DB row is real. installedId is set iff install
344
+ // resolved (the early `missingDeployTarget` / catch paths returned).
345
+ if (installedId !== null) plumbing.onInstalled(installedId);
346
+ }
347
+
348
+ // Auto-preview when host pre-fills the source. The host owns the
349
+ // file so there's no paste step; if preview fails we show the error
350
+ // in place of (the now-hidden) textarea.
351
+ useEffect(() => {
352
+ if (initialSource !== undefined) {
353
+ void onParse();
354
+ }
355
+ // eslint-disable-next-line react-hooks/exhaustive-deps
356
+ }, []);
357
+
358
+ const togglePermission = (path: string, v: boolean) => {
359
+ const next = new Set(enabled.value);
360
+ if (v) next.add(path);
361
+ else next.delete(path);
362
+ enabled.value = next;
363
+ };
364
+
365
+ // The worker scoping the Silo choice. A system app has no worker (null),
366
+ // and never could: a Silo is a directory on one box, so `host.silo` is
367
+ // single-only (`appKinds`, schema/host.ts) and the declaration is refused
368
+ // before an install ever gets here.
369
+ const siloWorkerId = useComputed(() =>
370
+ typeof deployTargets.value === "string" ? deployTargets.value : null,
371
+ );
372
+
373
+ async function onCreateSilo(input: { name: string; containerPath?: string; hostPath?: string }) {
374
+ const workerId = siloWorkerId.value;
375
+ if (!workerId) throw new Error("Pick a worker first.");
376
+ if (!plumbing.createSilo) throw new Error("This host can't create Silos.");
377
+ const created = await plumbing.createSilo({ ...input, workerId });
378
+ silos.value = [...silos.value, created];
379
+ return created;
380
+ }
381
+
382
+ async function onAcknowledgeSilo(id: string) {
383
+ await plumbing.acknowledgeSilo!(id);
384
+ // Clearing the command locally is the same derivation the host does, and it
385
+ // beats re-listing: a refetch mid-install could hand back a list the
386
+ // operator's own create isn't in yet.
387
+ silos.value = silos.value.map((s) => (s.id === id ? { ...s, setupCommand: null } : s));
388
+ }
389
+
390
+ function onDeployTargetsChange(v: DeployTargets | null) {
391
+ deployTargets.value = v;
392
+ // A Silo picked for the old worker can't resolve on the new one, and the
393
+ // picker filters it out of view. Drop it rather than install a grant
394
+ // naming a box the app no longer runs on.
395
+ const workerId = typeof v === "string" ? v : null;
396
+ const next = { ...grantValues.value };
397
+ let dropped = false;
398
+ for (const [path, id] of Object.entries(next)) {
399
+ if (preview.value?.permissionsFlat[path]?.kind !== "host.silo") continue;
400
+ if (silos.value.some((s) => s.id === id && s.workerId === workerId)) continue;
401
+ delete next[path];
402
+ dropped = true;
403
+ }
404
+ if (dropped) grantValues.value = next;
405
+ }
406
+
407
+ // Required perms whose optional-section ancestors are all active: toggling a
408
+ // section off drops its perms from what the install still needs.
409
+ const requiredNow = useComputed(() => {
410
+ if (!preview.value) return [] as string[];
411
+ const inactive = permsInInactiveSections(
412
+ preview.value.configFields,
413
+ config.value,
414
+ preview.value.permissionsFlat,
415
+ );
416
+ return Object.entries(preview.value.permissionsFlat)
417
+ .filter(([p, m]) => !(m.meta as { isOptional?: boolean }).isOptional && !inactive.has(p))
418
+ .map(([p]) => p);
419
+ });
420
+ const missingPerms = useComputed(() => requiredNow.value.filter((p) => !enabled.value.has(p)));
421
+ const missingConfig = useComputed(() => {
422
+ if (!preview.value) return [] as string[];
423
+ return findMissingRequired(preview.value.configFields, config.value);
424
+ });
425
+ // `host.silo` has no descriptor to fall back on, so an unpicked Silo emits
426
+ // `data: undefined` and sanitizeGrants 400s. Catch it as a disabled button
427
+ // rather than a server error after the operator hit Install.
428
+ const missingSilo = useComputed(() => {
429
+ if (!preview.value || !hostHasSilos) return [] as string[];
430
+ const inactive = permsInInactiveSections(
431
+ preview.value.configFields,
432
+ config.value,
433
+ preview.value.permissionsFlat,
434
+ );
435
+ return Object.entries(preview.value.permissionsFlat)
436
+ .filter(([p, m]) => m.kind === "host.silo" && enabled.value.has(p) && !inactive.has(p))
437
+ .filter(([p]) => typeof grantValues.value[p] !== "string")
438
+ .map(([p]) => p);
439
+ });
440
+ // A domain typed but never added would be dropped at submit with nothing to
441
+ // show for it — blank is a valid grant, so no other gate catches it.
442
+ const pendingDomain = useComputed(() => Object.values(pendingDomains.value).some(Boolean));
443
+ // Single apps must be pinned to exactly one worker. In dev mode
444
+ // (`hideDeployTargets`) there's no picker and the host's single local
445
+ // target stands in, so skip the gate. System apps have no target to
446
+ // pick and stay null.
447
+ const missingDeployTarget = useComputed(() => {
448
+ if (!preview.value || hideDeployTargets) return false;
449
+ if (preview.value.kind !== "single") return false;
450
+ return typeof deployTargets.value !== "string";
451
+ });
452
+
453
+ // Top-level tabs (key → display title). Empty for flat apps.
454
+ const tabs = useComputed(() => {
455
+ if (!preview.value) return [] as { key: string; title: string }[];
456
+ return Object.entries(preview.value.configFields)
457
+ .filter(([, f]) => f.type === "tab")
458
+ .map(([key, f]) => ({ key, title: (f as ConfigContainerNode).title ?? key }));
459
+ });
460
+ // Bucket every still-unmet required item (config field or permission) by
461
+ // the tab it lives under, so each tab can show a count badge and the
462
+ // summary can say "Need N settings under <tab>". Items not under any tab
463
+ // (flat apps, or top-level fields alongside tabs) fall into `noTab`.
464
+ const needs = useComputed(() => {
465
+ const byTab: Record<string, number> = {};
466
+ let noTab = 0;
467
+ const tabKeys = new Set(tabs.value.map((t) => t.key));
468
+ for (const path of [...missingPerms.value, ...missingConfig.value]) {
469
+ const top = path.split(".")[0]!;
470
+ if (tabKeys.has(top)) byTab[top] = (byTab[top] ?? 0) + 1;
471
+ else noTab += 1;
472
+ }
473
+ return { byTab, noTab };
474
+ });
475
+ // Human-readable "still needed" lines for the configure step.
476
+ const needsLines = useComputed(() => {
477
+ const lines: string[] = [];
478
+ const plural = (n: number) => (n === 1 ? "setting" : "settings");
479
+ for (const t of tabs.value) {
480
+ const n = needs.value.byTab[t.key] ?? 0;
481
+ if (n > 0) lines.push(`${n} ${plural(n)} under ${t.title}`);
482
+ }
483
+ if (needs.value.noTab > 0) lines.push(`${needs.value.noTab} ${plural(needs.value.noTab)}`);
484
+ if (missingDeployTarget.value) lines.push("to choose a worker to deploy to");
485
+ if (missingSilo.value.length > 0) {
486
+ // A system app is a dead end, not a missing step: the app asks to run
487
+ // everywhere and a Silo is a directory on one box. Say that instead of
488
+ // pointing at a picker the operator will never be offered.
489
+ lines.push(
490
+ preview.value?.kind === "system"
491
+ ? "a Silo, which this app can't have — it deploys to every worker, and a Silo lives on one"
492
+ : "to pick a Silo",
493
+ );
494
+ }
495
+ if (pendingDomain.value) lines.push("to add the domain you typed, or clear the field");
496
+ return lines;
497
+ });
498
+
499
+ // Host kinds — required vs optional — alongside which of those any worker can
500
+ // satisfy. Optional rows backed by an unsupported kind gray out; unsupported
501
+ // required kinds warn that the app installs dormant until a capable worker
502
+ // joins.
503
+ const partitioned = useComputed(() =>
504
+ preview.value
505
+ ? partitionHostKinds(preview.value.permissionsFlat, preview.value.configFields)
506
+ : { required: [], optional: [] },
507
+ );
508
+ const supportedKinds = useComputed(() => {
509
+ const set = new Set<WorkerCapability>();
510
+ for (const w of workers) {
511
+ for (const k of w.capabilities) set.add(k);
512
+ }
513
+ return set;
514
+ });
515
+ const unsupportedRequiredKinds = useComputed(() =>
516
+ partitioned.value.required.filter((k) => !supportedKinds.value.has(k)),
517
+ );
518
+ const unsupportedOptionalKinds = useComputed(
519
+ () =>
520
+ new Set(
521
+ partitioned.value.optional.filter((k) => !supportedKinds.value.has(k)),
522
+ ) as Set<HostPermissionKind>,
523
+ );
524
+
525
+ // Rendered at the top of BOTH the Review and configure steps: the worker
526
+ // scopes capability-gated permissions and the Silo choice, so it stays
527
+ // visible and changeable while those are filled in. Only one step is mounted
528
+ // at a time, so the two slots never collide.
529
+ const deployPicker =
530
+ hideDeployTargets || !preview.value ? null : (
531
+ <section class="flex flex-col gap-2">
532
+ <DeployTargetsPicker
533
+ workers={workers}
534
+ value={deployTargets.value}
535
+ kind={preview.value.kind}
536
+ onChange={onDeployTargetsChange}
537
+ disabled={installing.value}
538
+ requiredKinds={partitioned.value.required}
539
+ collapsible
540
+ />
541
+ </section>
542
+ );
543
+
544
+ return (
545
+ <div class="flex flex-col gap-6 p-6 max-w-3xl">
546
+ {step.value === "discover" && initialSource !== undefined && (
547
+ <section class="flex flex-col gap-2">
548
+ {parsing.value && <div class="text-xs text-muted-foreground">Loading realm.ts…</div>}
549
+ {error.value && <InstallError error={error.value} />}
550
+ </section>
551
+ )}
552
+
553
+ {step.value === "discover" && initialSource === undefined && (
554
+ <section class="flex flex-col gap-2">
555
+ <textarea
556
+ class="font-mono text-xs rounded-2xl border border-border bg-card p-3 min-h-48 w-full outline-none focus:ring-2 focus:ring-ring/50"
557
+ placeholder='import { createApp, r } from "@yourrealm/sdk"; …'
558
+ value={source.value}
559
+ onInput={(e) => (source.value = (e.target as HTMLTextAreaElement).value)}
560
+ disabled={parsing.value || installing.value}
561
+ data-testid="install-source"
562
+ />
563
+ <div class="flex items-center gap-2">
564
+ <Button
565
+ onClick={onParse}
566
+ disabled={parsing.value || installing.value || !source.value.trim()}
567
+ data-testid="parse-source"
568
+ >
569
+ {parsing.value ? "Parsing…" : "Parse"}
570
+ </Button>
571
+ <a href="/" class="text-xs text-muted-foreground hover:underline">
572
+ Cancel
573
+ </a>
574
+ </div>
575
+ {error.value && <InstallError error={error.value} />}
576
+ </section>
577
+ )}
578
+
579
+ {step.value === "summary" && preview.value && (
580
+ <>
581
+ {deployPicker}
582
+ <SummaryStep
583
+ preview={preview.value}
584
+ grantValues={grantValues.value}
585
+ onGrantValueChange={(path, value) =>
586
+ (grantValues.value = { ...grantValues.value, [path]: value })
587
+ }
588
+ onConfirm={() => (step.value = "configure")}
589
+ onBack={() => (step.value = "discover")}
590
+ formatAge={formatAge}
591
+ />
592
+ </>
593
+ )}
594
+
595
+ {step.value === "configure" && preview.value && (
596
+ <>
597
+ {deployPicker}
598
+
599
+ {Object.keys(preview.value.configFields).length +
600
+ Object.keys(preview.value.permissionsFlat).length ===
601
+ 0 &&
602
+ hideDeployTargets && (
603
+ <div class="rounded-2xl border border-border bg-card text-muted-foreground text-xs px-4 py-3">
604
+ Nothing to configure — this app declares no config fields or permissions. Click
605
+ Install app to continue.
606
+ </div>
607
+ )}
608
+ {Object.keys(preview.value.configFields).length +
609
+ Object.keys(preview.value.permissionsFlat).length >
610
+ 0 && (
611
+ <section class="flex flex-col gap-3">
612
+ <TabsForm
613
+ configFields={preview.value.configFields}
614
+ permissionsFlat={preview.value.permissionsFlat}
615
+ configValues={config.value}
616
+ enabledPermissions={enabled.value}
617
+ grantValues={grantValues.value}
618
+ silos={hostHasSilos ? silos.value : undefined}
619
+ siloWorkerId={siloWorkerId.value}
620
+ siloKind={preview.value.kind}
621
+ onCreateSilo={plumbing.createSilo ? onCreateSilo : undefined}
622
+ onAcknowledgeSilo={plumbing.acknowledgeSilo ? onAcknowledgeSilo : undefined}
623
+ disabled={installing.value}
624
+ needsByTab={needs.value.byTab}
625
+ onConfigChange={(path, v) => (config.value = setPath(config.value, path, v))}
626
+ onPermissionToggle={togglePermission}
627
+ onGrantValueChange={(path, value) =>
628
+ (grantValues.value = { ...grantValues.value, [path]: value })
629
+ }
630
+ onDomainPendingChange={(path, pending) =>
631
+ (pendingDomains.value = { ...pendingDomains.value, [path]: pending })
632
+ }
633
+ />
634
+ </section>
635
+ )}
636
+
637
+ {error.value && <InstallError error={error.value} />}
638
+
639
+ {unsupportedRequiredKinds.value.length > 0 && (
640
+ <div
641
+ class="rounded-2xl border border-border bg-muted/40 text-muted-foreground text-xs px-4 py-3"
642
+ data-testid="unsupported-required-kinds"
643
+ >
644
+ No worker currently satisfies:{" "}
645
+ <span class="font-mono font-semibold">
646
+ {unsupportedRequiredKinds.value.join(", ")}
647
+ </span>
648
+ . The app will install dormant and start running when a capable worker joins.
649
+ </div>
650
+ )}
651
+
652
+ {unsupportedOptionalKinds.value.size > 0 && (
653
+ <div class="rounded-2xl border border-border bg-card text-muted-foreground text-xs px-4 py-3">
654
+ Not supported by any worker:{" "}
655
+ <span class="font-mono font-semibold">
656
+ {[...unsupportedOptionalKinds.value].join(", ")}
657
+ </span>
658
+ </div>
659
+ )}
660
+
661
+ {needsLines.value.length > 0 && (
662
+ <div
663
+ class="rounded-2xl border border-amber-300/50 bg-amber-50 text-amber-900 text-xs px-4 py-3 flex flex-col gap-1"
664
+ data-testid="missing-required"
665
+ >
666
+ <span class="font-medium">Almost there — still need:</span>
667
+ <ul class="list-disc pl-4 flex flex-col gap-0.5">
668
+ {needsLines.value.map((line) => (
669
+ <li key={line}>{line}</li>
670
+ ))}
671
+ </ul>
672
+ </div>
673
+ )}
674
+
675
+ <div class="flex items-center gap-2">
676
+ <Button variant="outline" onClick={() => (step.value = "summary")}>
677
+ Back
678
+ </Button>
679
+ <Button
680
+ onClick={() => onInstall(false)}
681
+ disabled={
682
+ installing.value ||
683
+ missingPerms.value.length > 0 ||
684
+ missingConfig.value.length > 0 ||
685
+ missingDeployTarget.value ||
686
+ missingSilo.value.length > 0 ||
687
+ pendingDomain.value
688
+ }
689
+ data-testid="install-app"
690
+ >
691
+ {installing.value ? "Installing…" : "Install app"}
692
+ </Button>
693
+ {showInstallAsTemplate && (
694
+ <Button
695
+ variant="outline"
696
+ onClick={() => {
697
+ // Explain what a template does (and that previews drop their
698
+ // volumes) before stamping it — unless the operator already
699
+ // dismissed this template's notice level.
700
+ const hasVols = preview.value?.hasPersistentVolumes ?? false;
701
+ if (!shouldShowTemplateDialog(hasVols)) {
702
+ onInstall(true);
703
+ return;
704
+ }
705
+ // A prior (lower-level) dismissal pre-fills "don't show again".
706
+ dontShowAgain.value = dismissedLevel() > 0;
707
+ templateDialogOpen.value = true;
708
+ }}
709
+ disabled={
710
+ installing.value ||
711
+ missingPerms.value.length > 0 ||
712
+ missingConfig.value.length > 0 ||
713
+ missingDeployTarget.value ||
714
+ missingSilo.value.length > 0 ||
715
+ pendingDomain.value
716
+ }
717
+ data-testid="install-as-template"
718
+ >
719
+ Install as template
720
+ </Button>
721
+ )}
722
+ <a href="/" class="text-xs text-muted-foreground hover:underline">
723
+ Cancel
724
+ </a>
725
+ </div>
726
+ </>
727
+ )}
728
+
729
+ <Dialog
730
+ open={templateDialogOpen.value}
731
+ onOpenChange={(d: { open: boolean }) => (templateDialogOpen.value = d.open)}
732
+ >
733
+ <DialogContent data-testid="install-template-dialog">
734
+ <DialogHeader>
735
+ <DialogTitle>Install as template</DialogTitle>
736
+ <DialogDescription>
737
+ A template is scaffolding — it doesn't run on its own. A builder app (like Forge)
738
+ creates an app from it for each pull request, and those previews are torn down
739
+ automatically when the request closes.
740
+ </DialogDescription>
741
+ </DialogHeader>
742
+ {preview.value?.hasPersistentVolumes && (
743
+ <div
744
+ class="rounded-2xl border border-border bg-card text-muted-foreground text-xs px-4 py-3"
745
+ data-testid="template-volumes-note"
746
+ >
747
+ No action needed — apps created from this template won't have persistent volumes and
748
+ will be garbage collected.
749
+ </div>
750
+ )}
751
+ <label class="flex items-center gap-2 text-sm">
752
+ <Checkbox
753
+ checked={dontShowAgain.value}
754
+ onCheckedChange={(d: { checked: boolean }) =>
755
+ (dontShowAgain.value = d.checked === true)
756
+ }
757
+ />
758
+ Don't show this again
759
+ </label>
760
+ <DialogFooter>
761
+ <Button variant="outline" onClick={() => (templateDialogOpen.value = false)}>
762
+ Cancel
763
+ </Button>
764
+ <Button
765
+ onClick={() => {
766
+ // Record dismissal at THIS template's level (volumeless → 1,
767
+ // volumes → 2); setDismissedLevel never downgrades a prior one.
768
+ if (dontShowAgain.value) {
769
+ setDismissedLevel(
770
+ templateNoticeLevel(preview.value?.hasPersistentVolumes ?? false),
771
+ );
772
+ }
773
+ templateDialogOpen.value = false;
774
+ onInstall(true);
775
+ }}
776
+ data-testid="confirm-install-template"
777
+ >
778
+ Install as template
779
+ </Button>
780
+ </DialogFooter>
781
+ </DialogContent>
782
+ </Dialog>
783
+ </div>
784
+ );
785
+ }
786
+
787
+ // ---- Summary step -----------------------------------------------------------
788
+ interface SummaryProps {
789
+ preview: PreviewResult;
790
+ grantValues: Record<string, unknown>;
791
+ onGrantValueChange: (path: string, value: unknown) => void;
792
+ onConfirm: () => void;
793
+ onBack: () => void;
794
+ formatAge: (iso: string | null) => string;
795
+ }
796
+
797
+ function SummaryStep({
798
+ preview,
799
+ grantValues,
800
+ onGrantValueChange,
801
+ onConfirm,
802
+ onBack,
803
+ formatAge,
804
+ }: SummaryProps) {
805
+ // A permission is hidden here if ANY ancestor tab/section along its dotted
806
+ // path is optional - walking only the first segment misses perms inside an
807
+ // optional section that's itself inside a tab (e.g. `general.s3.bucket`).
808
+ // Those show up in the configure step once the feature toggle is on.
809
+ function isTopLevelPermission(path: string): boolean {
810
+ const segments = path.split(".");
811
+ // Skip the last segment (the permission name itself). Optionality
812
+ // on the leaf comes from meta.__optional, not from configFields.
813
+ let tree: Record<string, ConfigField> | undefined = preview.configFields;
814
+ for (let i = 0; i < segments.length - 1; i++) {
815
+ if (!tree) return true;
816
+ const node: ConfigField | undefined = tree[segments[i]!];
817
+ if (!node) return true;
818
+ if ((node.type === "section" || node.type === "tab") && node.optional) return false;
819
+ if (node.type === "tab" || node.type === "section") {
820
+ tree = node.fields;
821
+ } else {
822
+ return true;
823
+ }
824
+ }
825
+ return true;
826
+ }
827
+ const shown = Object.entries(preview.permissionsFlat).filter(([p]) => isTopLevelPermission(p));
828
+ const required = shown.filter(([, m]) => !(m.meta as { isOptional?: boolean }).isOptional);
829
+ const dangerousRequired = required.filter(
830
+ ([, m]) => PERMISSION_META[m.kind as PermissionKind]?.danger,
831
+ );
832
+ const normalRequired = required.filter(
833
+ ([, m]) => !PERMISSION_META[m.kind as PermissionKind]?.danger,
834
+ );
835
+ const renderRequiredRow = ([path, perm]: [string, SerializablePermission]) => {
836
+ const kindKey = perm.kind;
837
+ if (!(kindKey in PERMISSION_META)) return null;
838
+ // grantValues is sparse (only entries the admin actively edited);
839
+ // fall back to the descriptor's perm.data for the displayed value.
840
+ const portArg =
841
+ kindKey === "host.port"
842
+ ? ((grantValues[path] ?? perm.data) as string | number | undefined)
843
+ : undefined;
844
+ const urlArg =
845
+ kindKey === "remote.fetchUrl" || kindKey === "remote.artifact"
846
+ ? (perm.data as { url: string }).url
847
+ : undefined;
848
+ const titleLabel = portArg != null ? String(portArg) : undefined;
849
+ const reason = (perm.meta as { reason?: string }).reason ?? "";
850
+ return (
851
+ <PermissionRow
852
+ key={path}
853
+ kind={kindKey as PermissionKind}
854
+ reason={reason}
855
+ required
856
+ checked
857
+ onCheckedChange={() => {}}
858
+ label={titleLabel}
859
+ port={portArg}
860
+ onPortChange={kindKey === "host.port" ? (v) => onGrantValueChange(path, v) : undefined}
861
+ url={urlArg}
862
+ />
863
+ );
864
+ };
865
+ return (
866
+ <>
867
+ <section class="flex flex-col gap-3">
868
+ <h2 class="text-sm font-bold text-foreground">Review</h2>
869
+ {dangerousRequired.length > 0 && (
870
+ <PermissionSection danger label="Dangerous required permissions">
871
+ {dangerousRequired.map(renderRequiredRow)}
872
+ </PermissionSection>
873
+ )}
874
+ {normalRequired.length > 0 && (
875
+ <PermissionSection label="Required permissions">
876
+ {normalRequired.map(renderRequiredRow)}
877
+ </PermissionSection>
878
+ )}
879
+ {preview.images && preview.images.length > 0 && (
880
+ <div class="flex flex-col gap-3 bg-trust-bg rounded-3xl shadow-clay-blue p-4">
881
+ <SectionLabel
882
+ icon={<CubeIcon className="w-4 h-4 text-trust" />}
883
+ className="text-trust pl-0.5"
884
+ >
885
+ Images
886
+ </SectionLabel>
887
+ {preview.images.map((img) => (
888
+ <ImagePosture
889
+ key={img.ref}
890
+ image={img.ref}
891
+ digest={img.digest ?? "not yet pinned"}
892
+ runsAs={img.runsAs ?? "unknown (will resolve on install)"}
893
+ runsAsRoot={img.runsAsRoot}
894
+ exposes={img.exposes}
895
+ volumes={img.volumes}
896
+ age={formatAge(img.imageCreatedAt)}
897
+ />
898
+ ))}
899
+ </div>
900
+ )}
901
+ </section>
902
+ <div class="flex items-center gap-2">
903
+ <Button variant="outline" onClick={onBack}>
904
+ Back
905
+ </Button>
906
+ <Button onClick={onConfirm} data-testid="summary-continue">
907
+ Continue
908
+ </Button>
909
+ </div>
910
+ </>
911
+ );
912
+ }