@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,982 @@
1
+ // createApp lifecycle: validate the schema, build a default config shape
2
+ // from inputs' prefills + tab/section structure, deep-merge user config
3
+ // over defaults, hand the merged tree to getDesiredState, then
4
+ // resolveDesiredApp the result.
5
+
6
+ import { z } from "zod";
7
+ import {
8
+ resolveDesiredApp,
9
+ type TrustedHeaderSource,
10
+ type TrustedHeadersManifest,
11
+ type HomeApp,
12
+ } from "./resolve.ts";
13
+ import { deepMergeConfig } from "./deep-merge.ts";
14
+ import { HEADER_NAME_RE, HEADER_VALUE_RE } from "./http-header.ts";
15
+ import { checkPermissionAppKinds, checkSystemAppRoutes } from "./security.ts";
16
+ import type { Service } from "./service.ts";
17
+ import type { ServiceDefinition, SystemServiceDefinition } from "./service-definition.ts";
18
+ import type { ConfigSchema, InferConfigSchema, Node } from "./types.ts";
19
+ import { isInput, isSection, isTab } from "./types.ts";
20
+ import type { ConfigFieldLeaf, ConfigFieldTree, SerializablePermission } from "./sandbox.ts";
21
+ import type { Action, Loader, Page, UiConfig, Widget } from "./ui-surface.ts";
22
+ import type {
23
+ ActionManifest,
24
+ LoaderManifest,
25
+ ManifestHeader,
26
+ SurfaceManifest,
27
+ UiManifest,
28
+ WidgetManifest,
29
+ } from "./resolve.ts";
30
+ import { identityRef, REF_SYMBOL, type Ref } from "./ref.ts";
31
+
32
+ interface DesiredApp {
33
+ /**
34
+ * Map of service names to their definitions. Each key becomes the
35
+ * container's display name and the in-app DNS alias other services use
36
+ * to reach it. Service classes (e.g. `new Postgres(...)`) and plain
37
+ * `ServiceDefinition` objects can be mixed.
38
+ *
39
+ * Optional: a config-only app (no `services`) runs no container. An app
40
+ * may resolve to nothing and simply deploys nothing.
41
+ */
42
+ services?: Record<string, Service | ServiceDefinition>;
43
+ /**
44
+ * Set to `config.egress` from a `r.host.egress()` permission to allow
45
+ * outbound traffic from this app's containers. When absent or
46
+ * `undefined`, the per-app network is created with Docker's
47
+ * `internal: true` flag and containers can only reach peers they have
48
+ * explicit permissions for. Realm.ts authors thread this through
49
+ * themselves, the same way every other resolved permission flows
50
+ * from `config` into the desired state.
51
+ */
52
+ egress?: true;
53
+ }
54
+
55
+ /**
56
+ * The desired state a system app's `getDesiredState` returns: the same
57
+ * shape as {@link DesiredApp} but services are routeless
58
+ * ({@link SystemServiceDefinition}) — system apps have no routes, so a
59
+ * `router` key is a type error at authoring time. The resolved-state
60
+ * check (`checkSystemAppRoutes`) backs the type at runtime.
61
+ */
62
+ interface SystemDesiredApp {
63
+ /** Same as {@link DesiredApp.services}, minus `router`. */
64
+ services?: Record<string, Service | SystemServiceDefinition>;
65
+ /** Same as {@link DesiredApp.egress}. */
66
+ egress?: true;
67
+ }
68
+
69
+ /**
70
+ * The authenticated Home user, as seen by `getTrustedHeaders`. This is the
71
+ * person logged into Home reaching the app - through its authenticated
72
+ * subdomain, or through one of its dashboard surfaces - never an app-local
73
+ * account.
74
+ *
75
+ * `username` / `email` are `Ref<string>`, not plain strings: at the point
76
+ * `getTrustedHeaders` runs there is no real user (it is sampled at deploy
77
+ * time to extract the header mapping), and the value is filled in per
78
+ * request on whichever door the request came through. Pass them straight
79
+ * through as a header value or compose with `interpolate`. `role` is a real
80
+ * literal so authors can branch on it.
81
+ */
82
+ export interface User {
83
+ username: Ref<string>;
84
+ email: Ref<string>;
85
+ role: "admin" | "member";
86
+ }
87
+
88
+ /**
89
+ * Per-install preview context, passed to `getDesiredState` as `preview`.
90
+ *
91
+ * `null` for a normal (persistent) install; a `{ previewKey }` object when
92
+ * Home stamps this app as an ephemeral `role: preview` child of a template
93
+ * (one per PR). The `previewKey` is opaque to the app (a PR number on GitHub,
94
+ * an MR id on GitLab, …) and DNS-label-safe, so it's safe to fold straight
95
+ * into a hostname or an id. The idiom is to branch a per-preview identity off
96
+ * it - a base URL, a bucket prefix, a database name:
97
+ *
98
+ * ```ts
99
+ * getDesiredState: ({ config, preview }) => ({
100
+ * services: {
101
+ * web: {
102
+ * image: "...",
103
+ * env: {
104
+ * SITE_URL: preview
105
+ * ? `https://pr-${preview.previewKey}.example.com`
106
+ * : config.siteUrl,
107
+ * },
108
+ * },
109
+ * },
110
+ * })
111
+ * ```
112
+ *
113
+ * Not a per-preview *public domain*: `r.home.publicDomain()` is an operator
114
+ * grant and a child inherits its template's grants verbatim, so every child
115
+ * would claim the same host. Home clears the resolved value on any preview
116
+ * child rather than trusting the source not to wire it, so previews ride the
117
+ * gated auto subdomain.
118
+ */
119
+ export interface PreviewContext {
120
+ previewKey: string;
121
+ }
122
+
123
+ /**
124
+ * One operator-facing value surfaced by `info` on the app's summary page. A
125
+ * **bare value** - a string, a `Ref<string>`, or `null` (a service URL Home
126
+ * drops) - or a **rich object** that adds a label, description, and mask/copy
127
+ * flags.
128
+ */
129
+ type InfoValue =
130
+ | string
131
+ | null
132
+ | Ref<string>
133
+ | {
134
+ value: string | null | Ref<string>;
135
+ /** Display label, distinct from the dict key. Falls back to the key. */
136
+ label?: string;
137
+ /** One line of context rendered as muted subtext under the value. */
138
+ description?: string;
139
+ /** Mask with a reveal control; not shown in plaintext by default. */
140
+ sensitive?: boolean;
141
+ /** Show a copy button (default: true). */
142
+ copyable?: boolean;
143
+ };
144
+
145
+ /**
146
+ * `info`'s signature: a `services` URL map in, a dict of {@link InfoValue}s
147
+ * out (`undefined` keys omitted). See platform/docs realm-ts/info.mdx.
148
+ */
149
+ type InfoFn = (args: {
150
+ services: Record<string, string | null>;
151
+ }) => Record<string, InfoValue | undefined>;
152
+
153
+ // Everything common to both factories. `info`, `getTrustedHeaders` and
154
+ // `verifyAnonymousRequest` are layered on by `SingleAppOptions` — all three
155
+ // hang off an app's routes, and system apps have none. The desired state type
156
+ // is a parameter so `createSystemApp` can narrow services to the routeless
157
+ // `SystemServiceDefinition`.
158
+ interface BaseAppOptions<TSchema extends ConfigSchema, TState extends DesiredApp> {
159
+ /**
160
+ * Stable, human-readable identifier shown in the dashboard. Free-form
161
+ * text - the per-install app id (used in container/network names) is
162
+ * generated separately and isn't derived from this.
163
+ */
164
+ name: string;
165
+ /**
166
+ * One-sentence description shown next to the app name in the dashboard
167
+ * and on the consent screen.
168
+ */
169
+ description?: string;
170
+ /**
171
+ * Typed tree of permissions, inputs, sections, and tabs the admin
172
+ * configures on the consent screen. Either every top-level entry is an
173
+ * `r.tab(...)` or none of them are - mixing throws at `createApp` time.
174
+ */
175
+ configSchema?: TSchema;
176
+ /**
177
+ * Pages and widgets contributed to the Home dashboard. Render fns are
178
+ * extracted by realm-sdk and bundled for the iframe runtime; loader
179
+ * schemas become a JSON manifest Home uses to validate proxy calls
180
+ * and route to the named service.
181
+ */
182
+ ui?: UiConfig;
183
+ /**
184
+ * Pure function that turns the resolved config into the app's desired
185
+ * services. Called by Home on every reconcile. Read fields off
186
+ * `config` (typed from `configSchema`) and return a `services` map;
187
+ * the SDK handles permission checks, ref resolution, and compose
188
+ * rendering downstream.
189
+ */
190
+ getDesiredState: (args: {
191
+ /**
192
+ * The resolved config tree. Inputs land as their primitive value;
193
+ * permissions land as their resolved shape (e.g. `host.port` ->
194
+ * `{ path }`, `host.port` -> `{ port }`); peer permissions land as
195
+ * the aggregated peer data; remote permissions land as parsed
196
+ * fetched data.
197
+ *
198
+ * Per-worker context (worker id + Home's `home_state` identity) lands
199
+ * under `host.management()` when granted - read `config.<path>.workerId`
200
+ * etc. rather than expecting a separate `worker` arg.
201
+ */
202
+ config: InferConfigSchema<TSchema>;
203
+ /**
204
+ * `null` on a normal install; a `{ previewKey }` object when Home stamps
205
+ * this app as an ephemeral preview child of a template. Branch on it to
206
+ * vary per-preview state (typically a public domain). See
207
+ * {@link PreviewContext}.
208
+ */
209
+ preview: PreviewContext | null;
210
+ }) => TState;
211
+ }
212
+
213
+ /**
214
+ * Options for `createApp` (a single app): runs on one selected worker. The
215
+ * install panel shows a single-pick; UI loaders forward to that one worker
216
+ * and `merge` is not allowed. Only single apps carry `info` and the two
217
+ * route-bound auth hooks.
218
+ */
219
+ interface SingleAppOptions<TSchema extends ConfigSchema = ConfigSchema> extends BaseAppOptions<
220
+ TSchema,
221
+ DesiredApp
222
+ > {
223
+ /**
224
+ * Header brokering: turn the authenticated Home user into the request
225
+ * headers this app trusts for sign-in (e.g. Miniflux's
226
+ * `AUTH_PROXY_HEADER`). Returns a flat `header -> value` map; Home
227
+ * computes it per request at the ForwardAuth hop and Caddy injects the
228
+ * result on the app's authenticated subdomain. The same header names are
229
+ * stripped from every other path (the public route, the client's own
230
+ * copy) so identity can't be spoofed.
231
+ *
232
+ * Header *names* must be static: Home samples this function once per role
233
+ * at eval time and unions the keys. A header *value* is a bare `user`
234
+ * field, a constant, or an `interpolate` template mixing the two
235
+ * (`` i`Bearer ${user.username}` ``). A transformed expression
236
+ * (`user.username.toUpperCase()`) won't round-trip and is rejected.
237
+ *
238
+ * Declaring this is the opt-in; installing an app that declares it is the
239
+ * operator's consent to forward identity to it.
240
+ */
241
+ getTrustedHeaders?: (user: User) => Record<string, string | Ref<string>>;
242
+ /**
243
+ * Anonymous gate: admit session-less machine callers (a webhook, a
244
+ * `docker pull`) that carry their own credential instead of a Home
245
+ * session. Home runs this at the ForwardAuth hop for every request to
246
+ * this app that arrives without a usable Home session.
247
+ *
248
+ * Return `true` to admit the request as the built-in `anonymous`
249
+ * principal, a `Response` to relay to the caller verbatim (e.g. a 401
250
+ * auth challenge), or `false` to deny. The hook can only ever govern
251
+ * its own app's routes.
252
+ *
253
+ * `request` carries the original call's method, URL, and headers - not
254
+ * its body (the verify hop never receives it). `stableSecret(name)`
255
+ * returns the live value of `r.stableSecret(name)` for this app, derived
256
+ * by Home; compare the incoming credential against it. The call is
257
+ * synchronous - no `await` - even though Home derives it asynchronously.
258
+ */
259
+ verifyAnonymousRequest?: (
260
+ request: Request,
261
+ stableSecret: (name: string) => string,
262
+ ) => boolean | Response;
263
+ /**
264
+ * Operator-facing setup values - a webhook URL, a generated secret, a
265
+ * derived string - surfaced on the app's summary page. See platform/docs
266
+ * realm-ts/info.mdx.
267
+ */
268
+ info?: InfoFn;
269
+ }
270
+
271
+ /**
272
+ * Options for `createSystemApp`: the app runs on every compatible worker —
273
+ * the operator has no per-worker choice; the install panel shows a static
274
+ * caption instead of a picker. UI loaders fan out to every reachable worker
275
+ * and `merge` is required on every loader.
276
+ *
277
+ * System apps have no routes, so the route-bound options are unavailable:
278
+ * `info` (a fanned-out service has one URL *per worker*, so there's no
279
+ * single URL for `services.<name>` to resolve to) and
280
+ * `verifyAnonymousRequest` (anonymous gates admit callers to an app's
281
+ * routes).
282
+ *
283
+ * `getTrustedHeaders` is banned for a different reason, and not a
284
+ * route-shaped one - brokered identity also reaches a container through a
285
+ * page or widget loader, which a system app very much has. The reason is
286
+ * consent: a system app is seeded by the platform with no operator install
287
+ * click, so disclosing who is signed in would be granted by the platform
288
+ * rather than by the operator. Revisit if a system app ever needs a viewer;
289
+ * none does today.
290
+ *
291
+ * The `never` tombstones hold for spread and variable-typed options, not
292
+ * just literals.
293
+ *
294
+ * See platform/docs realm-ts/workers.mdx.
295
+ */
296
+ interface SystemAppOptions<TSchema extends ConfigSchema = ConfigSchema> extends BaseAppOptions<
297
+ TSchema,
298
+ SystemDesiredApp
299
+ > {
300
+ info?: never;
301
+ getTrustedHeaders?: never;
302
+ verifyAnonymousRequest?: never;
303
+ }
304
+
305
+ export interface AppDefinition<TSchema extends ConfigSchema = ConfigSchema> {
306
+ name: string;
307
+ description?: string;
308
+ kind: "single" | "system";
309
+ /**
310
+ * Raw anonymous gate, retained on the definition so Home can call it
311
+ * inside the sandbox at the verify hop (unlike `getTrustedHeaders`,
312
+ * which is sampled to a static manifest, the gate is live logic over
313
+ * the request and must execute).
314
+ */
315
+ verifyAnonymousRequest?: (
316
+ request: Request,
317
+ stableSecret: (name: string) => string,
318
+ ) => boolean | Response;
319
+ configSchema?: TSchema;
320
+ /**
321
+ * Author-provided ui block, retained so the iframe bundle can reach
322
+ * each surface's `render` fn. Loader runtime values stay here too —
323
+ * only the JSON manifest is stripped down for `evaluate`'s output.
324
+ */
325
+ ui?: UiConfig;
326
+ /** Raw `info` fn, retained so Home can run it in the sandbox on the
327
+ * summary-page path. */
328
+ info?: InfoFn;
329
+ /**
330
+ * Attach file-based surfaces (pages/*, widgets/*) after construction. Called
331
+ * only by the merge's generated glue — it imports the surface files and
332
+ * wires them in before the app is bundled, so a file-based app needs no `ui:`
333
+ * block and the merge runs with esbuild alone (no TypeScript compiler,
334
+ * which is why it can run in Home). Throws if an inline `ui:` is already set.
335
+ */
336
+ attachSurfaces: (ui: UiConfig) => void;
337
+ evaluate: (args: {
338
+ config: InferConfigSchema<TSchema>;
339
+ appId?: string;
340
+ preview?: PreviewContext | null;
341
+ }) => HomeApp;
342
+ }
343
+
344
+ // Plain JS identifier shape — keys appear in dotted paths
345
+ // ("general.webhooks"), filesystem cache names, and getDesiredState as
346
+ // `config.general.webhooks`.
347
+ const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
348
+
349
+ function assertValidKeys(schema: Record<string, Node>, prefix = ""): void {
350
+ for (const [key, node] of Object.entries(schema)) {
351
+ if (!IDENT_RE.test(key)) {
352
+ const where = prefix ? `at "${prefix}.${key}"` : `at "${key}"`;
353
+ throw new Error(
354
+ `configSchema key must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ ${where} - ` +
355
+ `use identifier-shaped names (letters, digits, underscore)`,
356
+ );
357
+ }
358
+ if (isTab(node) || isSection(node)) {
359
+ assertValidKeys(node.children as Record<string, Node>, prefix ? `${prefix}.${key}` : key);
360
+ }
361
+ }
362
+ }
363
+
364
+ function assertSchemaHomogeneous(schema: Record<string, Node>): void {
365
+ const keys = Object.keys(schema);
366
+ if (keys.length === 0) return;
367
+ const tabs = keys.filter((k) => isTab(schema[k]!));
368
+ if (tabs.length > 0 && tabs.length !== keys.length) {
369
+ const first = tabs[0]!;
370
+ const other = keys.find((k) => !isTab(schema[k]!))!;
371
+ throw new Error(
372
+ `configSchema must be either all r.tab(...) or none - "${first}" is a tab, "${other}" is not`,
373
+ );
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Flatten the authoring tree to dotted-path → permission node, the same
379
+ * shape (and the same paths) `extractSchema` produces inside QuickJS. A
380
+ * permission node is whatever isn't a tab, a section or an input, which
381
+ * is how the sandbox walk classifies them too.
382
+ *
383
+ * Exists so the factories can run `checkPermissionAppKinds` against the
384
+ * live schema, before any bundling or eval. Home runs the same check on
385
+ * the extracted `permsFlat`.
386
+ */
387
+ function collectPermissionNodes(
388
+ schema: Record<string, Node>,
389
+ out: Record<string, SerializablePermission> = {},
390
+ prefix = "",
391
+ ): Record<string, SerializablePermission> {
392
+ for (const [key, node] of Object.entries(schema)) {
393
+ const path = prefix ? `${prefix}.${key}` : key;
394
+ if (isTab(node) || isSection(node)) {
395
+ collectPermissionNodes(node.children as Record<string, Node>, out, path);
396
+ } else if (!isInput(node)) {
397
+ // A permission record is already `{kind, data, meta}` plus chain methods;
398
+ // the sandbox serializer drops the methods and reads the same three fields.
399
+ out[path] = node as unknown as SerializablePermission;
400
+ }
401
+ }
402
+ return out;
403
+ }
404
+
405
+ const OMIT = Symbol("omit");
406
+
407
+ function buildConfigShape(schema: Record<string, Node>): Record<string, unknown> {
408
+ const out: Record<string, unknown> = {};
409
+ for (const [key, node] of Object.entries(schema)) {
410
+ const built = buildNodeShape(node);
411
+ if (built !== OMIT) out[key] = built;
412
+ }
413
+ return out;
414
+ }
415
+
416
+ function buildNodeShape(node: Node): unknown {
417
+ if (isTab(node)) {
418
+ const sub: Record<string, unknown> = {};
419
+ for (const [k, child] of Object.entries(node.children)) {
420
+ const built = buildNodeShape(child as Node);
421
+ if (built !== OMIT) sub[k] = built;
422
+ }
423
+ return sub;
424
+ }
425
+
426
+ if (isSection(node)) {
427
+ if (node.meta.isOptional) return OMIT;
428
+ const sub: Record<string, unknown> = {};
429
+ for (const [k, child] of Object.entries(node.children)) {
430
+ const built = buildNodeShape(child as Node);
431
+ if (built !== OMIT) sub[k] = built;
432
+ }
433
+ return sub;
434
+ }
435
+
436
+ if (isInput(node)) {
437
+ const value = (node.meta as { prefill?: unknown }).prefill;
438
+ if (value !== undefined) return value;
439
+ return OMIT;
440
+ }
441
+
442
+ // Permission leaf: admin must grant; defaults don't apply.
443
+ return OMIT;
444
+ }
445
+
446
+ // ----- getTrustedHeaders sampling -------------------------------------------
447
+ //
448
+ // Header *names* must be static so Caddy can list them for copy_headers
449
+ // (inject) and delete (anti-spoof) without seeing a request. Sampling finds
450
+ // them: call getTrustedHeaders once per role with identity refs for
451
+ // username/email (and the real role, so role-branching works), then read each
452
+ // returned value as a `{field}` reference, a `{literal}` string, or a
453
+ // `{template}` of literal + field segments (an `interpolate` over identity,
454
+ // e.g. `i`Bearer ${user.username}``). The verify hop substitutes the live
455
+ // value; role itself rides along as a literal captured in each sample.
456
+
457
+ const AUTH_ROLES = ["admin", "member"] as const;
458
+
459
+ function sampleTrustedHeaders(
460
+ fn: (user: User) => Record<string, string | Ref<string>>,
461
+ ): TrustedHeadersManifest {
462
+ const keys = new Set<string>();
463
+ const byRole = {} as TrustedHeadersManifest["byRole"];
464
+ for (const role of AUTH_ROLES) {
465
+ const out = fn({ username: identityRef("username"), email: identityRef("email"), role });
466
+ const map: Record<string, TrustedHeaderSource> = {};
467
+ for (const [key, value] of Object.entries(out)) {
468
+ if (!HEADER_NAME_RE.test(key)) {
469
+ throw new Error(
470
+ `getTrustedHeaders["${key}"]: not a valid HTTP header name (letters, digits, ` +
471
+ "and !#$%&'*+-.^_`|~). Every call into the app carries these headers, and a " +
472
+ "malformed name is rejected outright",
473
+ );
474
+ }
475
+ // `X-Realm-*` is the platform's namespace: Home and the worker stamp into
476
+ // it and Caddy strips it off every client request, so a container is
477
+ // entitled to trust whatever arrives under it. An app declaring into it
478
+ // would break that reservation, and a name Home later stamps would
479
+ // silently take over the app's value.
480
+ if (key.toLowerCase().startsWith("x-realm-")) {
481
+ throw new Error(
482
+ `getTrustedHeaders["${key}"]: the X-Realm-* namespace is reserved for the ` +
483
+ "platform. Home and the worker stamp headers there and clients are stripped " +
484
+ "of them, so a container can trust them; pick a name outside it",
485
+ );
486
+ }
487
+ keys.add(key);
488
+ map[key] = toTrustedHeaderSource(key, value);
489
+ }
490
+ byRole[role] = map;
491
+ }
492
+ return { keys: [...keys], byRole };
493
+ }
494
+
495
+ // Literal text an author baked into a header value, checked against the same
496
+ // rule the worker applies at request time. Literals only: a `{field}` is filled
497
+ // per request from the live session, and its charset is bounded at account
498
+ // creation, not here.
499
+ function assertHeaderLiteral(key: string, text: string): void {
500
+ if (HEADER_VALUE_RE.test(text)) return;
501
+ throw new Error(
502
+ `getTrustedHeaders["${key}"]: value must be printable ASCII. A character outside ` +
503
+ "it is rejected on the way into the container, failing every call the app makes",
504
+ );
505
+ }
506
+
507
+ // A returned header value is a literal string, a single user field (a bare
508
+ // identity ref), or an `interpolate` template mixing literal text with user
509
+ // fields (e.g. `i`Bearer ${user.username}``). Other ref kinds (secret, derived,
510
+ // deferred) and any field other than username/email are rejected.
511
+ function toTrustedHeaderSource(key: string, value: string | Ref<string>): TrustedHeaderSource {
512
+ if (typeof value === "string") {
513
+ assertHeaderLiteral(key, value);
514
+ return { literal: value };
515
+ }
516
+ if (typeof value === "object" && value !== null && REF_SYMBOL in value) {
517
+ const json = (value as Ref<string>).toJSON();
518
+ if (typeof json === "string") {
519
+ assertHeaderLiteral(key, json);
520
+ return { literal: json };
521
+ }
522
+ const marker = (json as { __realmRef: { kind?: string; field?: string; parts?: unknown[] } })
523
+ .__realmRef;
524
+ if (marker.kind === "identity") return { field: identityField(key, marker.field) };
525
+ // `interpolate` flattens to a single concat of string + leaf segments;
526
+ // for identity headers every leaf must itself be an identity field.
527
+ if (marker.kind === "concat") {
528
+ return { template: (marker.parts ?? []).map((part) => headerSegment(key, part)) };
529
+ }
530
+ }
531
+ throw new Error(`getTrustedHeaders["${key}"]: value must be a user field or a literal string`);
532
+ }
533
+
534
+ // One segment of an interpolated header value: literal text passes through,
535
+ // an identity leaf becomes a `{field}` the verify hop fills per request.
536
+ function headerSegment(key: string, part: unknown): string | { field: "username" | "email" } {
537
+ if (typeof part === "string") {
538
+ assertHeaderLiteral(key, part);
539
+ return part;
540
+ }
541
+ const m = part as { kind?: string; field?: string };
542
+ if (m?.kind === "identity") return { field: identityField(key, m.field) };
543
+ throw new Error(
544
+ `getTrustedHeaders["${key}"]: only user fields (user.username / user.email) and literal text can compose a header value`,
545
+ );
546
+ }
547
+
548
+ function identityField(key: string, field: string | undefined): "username" | "email" {
549
+ if (field === "username" || field === "email") return field;
550
+ throw new Error(`getTrustedHeaders["${key}"]: unknown user field "${field}"`);
551
+ }
552
+
553
+ // ----- ui validation + manifest extraction -------------------------------
554
+
555
+ function assertBaseSurface(surface: Page, where: string): void {
556
+ if (typeof surface.title !== "string" || surface.title.length === 0) {
557
+ throw new Error(`${where}.title must be a non-empty string`);
558
+ }
559
+ if (typeof surface.icon !== "string" || surface.icon.length === 0) {
560
+ throw new Error(`${where}.icon must be a non-empty string from the icon registry`);
561
+ }
562
+ if (typeof surface.render !== "function") {
563
+ throw new Error(`${where}.render must be a function`);
564
+ }
565
+ if (surface.loaders) {
566
+ for (const [lk, l] of Object.entries(surface.loaders)) {
567
+ if (!IDENT_RE.test(lk)) {
568
+ throw new Error(`${where}.loaders key "${lk}" must be an identifier`);
569
+ }
570
+ if (typeof l.service !== "string" || typeof l.port !== "number") {
571
+ throw new Error(`${where}.loaders.${lk} needs a string service and a number port`);
572
+ }
573
+ if (typeof l.request !== "function") {
574
+ throw new Error(`${where}.loaders.${lk}.request must be a (state, config) => request fn`);
575
+ }
576
+ }
577
+ }
578
+ if (surface.actions) {
579
+ for (const [ak, a] of Object.entries(surface.actions)) {
580
+ if (!IDENT_RE.test(ak)) {
581
+ throw new Error(`${where}.actions key "${ak}" must be an identifier`);
582
+ }
583
+ if (typeof a.service !== "string" || typeof a.port !== "number") {
584
+ throw new Error(`${where}.actions.${ak} needs a string service and a number port`);
585
+ }
586
+ if (typeof a.request !== "function") {
587
+ throw new Error(`${where}.actions.${ak}.request must be an (input, config) => request fn`);
588
+ }
589
+ }
590
+ }
591
+ }
592
+
593
+ function assertPageMap(pages: Record<string, Page>): void {
594
+ for (const [key, p] of Object.entries(pages)) {
595
+ if (!IDENT_RE.test(key)) {
596
+ throw new Error(
597
+ `ui.pages key must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ at "${key}" - ` +
598
+ `use identifier-shaped names (letters, digits, underscore)`,
599
+ );
600
+ }
601
+ assertBaseSurface(p, `ui.pages.${key}`);
602
+ }
603
+ }
604
+
605
+ function assertWidgetMap(widgets: Record<string, Widget>): void {
606
+ for (const [key, w] of Object.entries(widgets)) {
607
+ if (!IDENT_RE.test(key)) {
608
+ throw new Error(
609
+ `ui.widgets key must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ at "${key}" - ` +
610
+ `use identifier-shaped names (letters, digits, underscore)`,
611
+ );
612
+ }
613
+ assertBaseSurface(w, `ui.widgets.${key}`);
614
+ for (const dim of ["cols", "rows"] as const) {
615
+ const v = w[dim];
616
+ if (v === undefined) continue;
617
+ if (!Number.isInteger(v) || v < 1) {
618
+ throw new Error(`ui.widgets.${key}.${dim} must be an integer ≥ 1`);
619
+ }
620
+ }
621
+ }
622
+ }
623
+
624
+ function assertValidUi(ui: UiConfig): void {
625
+ if (ui.pages) assertPageMap(ui.pages);
626
+ if (ui.widgets) assertWidgetMap(ui.widgets);
627
+ }
628
+
629
+ // A system app fans out to every worker, so the iframe runtime needs a `merge`
630
+ // reducer to combine per-worker loader responses. A single app only ever has one
631
+ // response, where a `merge` would never run — reject it rather than let authors
632
+ // think it is wired up. Actions take no `merge` either way: a system app's
633
+ // resolve with the raw `{results: [...]}` envelope, a single app's with the
634
+ // single upstream body.
635
+ function assertMergeMatchesKind(ui: UiConfig, kind: "single" | "system"): void {
636
+ const visit = (where: string, surface: Page) => {
637
+ if (!surface.loaders) return;
638
+ for (const [name, l] of Object.entries(surface.loaders)) {
639
+ const hasMerge = typeof l.merge === "function";
640
+ if (kind === "system" && !hasMerge) {
641
+ throw new Error(
642
+ `${where}.loaders.${name}: createSystemApp requires merge(results) on every loader`,
643
+ );
644
+ }
645
+ if (kind === "single" && hasMerge) {
646
+ throw new Error(
647
+ `${where}.loaders.${name}: createApp forbids merge — only one worker responds`,
648
+ );
649
+ }
650
+ }
651
+ };
652
+ if (ui.pages) for (const [k, p] of Object.entries(ui.pages)) visit(`ui.pages.${k}`, p);
653
+ if (ui.widgets) for (const [k, w] of Object.entries(ui.widgets)) visit(`ui.widgets.${k}`, w);
654
+ }
655
+
656
+ function headerToManifest(value: string | { toJSON?: () => unknown }): ManifestHeader {
657
+ if (typeof value === "string") return value;
658
+ if (value && typeof value === "object" && REF_SYMBOL in value) {
659
+ const json = (value as { toJSON: () => unknown }).toJSON();
660
+ return json as ManifestHeader;
661
+ }
662
+ throw new Error("ui proxy headers must be strings or refs (e.g. r.stableSecret(...))");
663
+ }
664
+
665
+ function headersToManifest(
666
+ headers: Record<string, string | { toJSON?: () => unknown }> | undefined,
667
+ ): Record<string, ManifestHeader> | undefined {
668
+ if (!headers) return undefined;
669
+ const out: Record<string, ManifestHeader> = {};
670
+ for (const [k, v] of Object.entries(headers)) out[k] = headerToManifest(v);
671
+ return out;
672
+ }
673
+
674
+ // Zod v4's toJSONSchema throws on z.void() / z.undefined() because they
675
+ // have no JSON representation. Loaders rarely use void but actions
676
+ // frequently return nothing — coerce both to an empty object schema
677
+ // (matches anything, which is the right semantic for "no return").
678
+ function toJsonSchemaSafe(schema: unknown): unknown {
679
+ try {
680
+ return z.toJSONSchema(schema as Parameters<typeof z.toJSONSchema>[0]);
681
+ } catch (e) {
682
+ if (e instanceof Error && e.message.includes("cannot be represented in JSON Schema")) {
683
+ return {};
684
+ }
685
+ throw e;
686
+ }
687
+ }
688
+
689
+ // The loader's static shape. Which loaders prefill - and the request each uses
690
+ // - is decided at shell-render time: Home runs each loader's
691
+ // `request(seedState, config)` live in a warm VM with the placement's real
692
+ // config. See `stitchLoaderRequests` in sandbox.ts.
693
+ function loaderToManifest(loader: Loader): LoaderManifest {
694
+ const manifest: LoaderManifest = {
695
+ outputSchema: toJsonSchemaSafe(loader.outputSchema),
696
+ service: loader.service,
697
+ port: loader.port,
698
+ headers: headersToManifest(loader.headers),
699
+ };
700
+ if (loader.merge) manifest.merge = true;
701
+ if (manifest.headers === undefined) delete manifest.headers;
702
+ return manifest;
703
+ }
704
+
705
+ function actionToManifest(action: Action): ActionManifest {
706
+ const manifest: ActionManifest = {
707
+ inputSchema: toJsonSchemaSafe(action.inputSchema),
708
+ service: action.service,
709
+ port: action.port,
710
+ headers: headersToManifest(action.headers),
711
+ };
712
+ if (action.outputSchema) manifest.outputSchema = toJsonSchemaSafe(action.outputSchema);
713
+ if (manifest.headers === undefined) delete manifest.headers;
714
+ return manifest;
715
+ }
716
+
717
+ // Walk a ui surface's `configSchema` — a flat record of `r.input.*` leaves —
718
+ // into the three wire forms Home needs: a `configFields` render descriptor for
719
+ // the config screen, a `configSchema` JSON Schema for validating a submitted
720
+ // config, and a `defaultConfig` seeded from each leaf's `.prefill()`.
721
+ //
722
+ // Rejects anything a surface config may not hold: a permission / tab / section
723
+ // leaf, or an `r.input.secret()`. A widget config is a per-placement display
724
+ // knob, not a credential store, and a secret can't ride the in-iframe
725
+ // `request(state, config)` without leaking to the sandbox; credentials live in
726
+ // the app's top-level configSchema and reach a loader via a header ref. The
727
+ // type-level `SurfaceConfigSchema` rejects it at authoring time, so this covers
728
+ // the packed/JS path.
729
+ function describeSurfaceConfig(schema: Record<string, Node>): {
730
+ fields: ConfigFieldTree;
731
+ defaultConfig: Record<string, unknown>;
732
+ validation: z.ZodType;
733
+ } {
734
+ const fields: ConfigFieldTree = {};
735
+ const shape: Record<string, z.ZodType> = {};
736
+ const defaultConfig: Record<string, unknown> = {};
737
+ for (const [key, node] of Object.entries(schema)) {
738
+ if (!isInput(node)) {
739
+ throw new Error(
740
+ `ui surface configSchema "${key}": only r.input.* leaves are allowed ` +
741
+ `(no permissions, tabs, or sections).`,
742
+ );
743
+ }
744
+ if (node.kind === "input.secret") {
745
+ throw new Error(
746
+ `ui surface configSchema "${key}": r.input.secret() isn't supported in a ` +
747
+ `surface config - a widget config holds display settings, not credentials. ` +
748
+ `Declare the secret in the app's top-level configSchema and pass its ref to a loader header.`,
749
+ );
750
+ }
751
+ const specific = node.kind.slice("input.".length) as ConfigFieldLeaf["type"];
752
+ const meta = (node.meta ?? {}) as Record<string, unknown>;
753
+ const leaf: ConfigFieldLeaf = { type: specific };
754
+ if (meta.title) leaf.label = meta.title as string;
755
+ if (meta.placeholder !== undefined) leaf.placeholder = meta.placeholder as string | number;
756
+ if (meta.prefill !== undefined) {
757
+ leaf.default = meta.prefill;
758
+ defaultConfig[key] = meta.prefill;
759
+ }
760
+ if (meta.description) leaf.description = meta.description as string;
761
+ if (meta.isOptional) leaf.optional = true;
762
+ if (specific === "enum" && "values" in node) {
763
+ leaf.values = [...(node as { values: readonly string[] }).values];
764
+ }
765
+ if (specific === "number") {
766
+ if (meta.int) leaf.step = 1;
767
+ if (meta.min !== undefined) leaf.min = meta.min as number;
768
+ if (meta.max !== undefined) leaf.max = meta.max as number;
769
+ }
770
+ fields[key] = leaf;
771
+ // A field that is `.optional()` or carries a `.prefill()` need not be
772
+ // submitted: the prefill rides `defaultConfig` as a base layer Home merges in
773
+ // before running loaders. Only builders like `.min()`/`.max()` tighten
774
+ // `node.schema`, while `.optional()`/`.prefill()` live purely in `meta`, so
775
+ // they are marked non-required here or `z.object` rejects a blank leaf.
776
+ shape[key] =
777
+ meta.isOptional || meta.prefill !== undefined ? node.schema.optional() : node.schema;
778
+ }
779
+ return { fields, defaultConfig, validation: z.object(shape) };
780
+ }
781
+
782
+ // Shared surface manifest bits (title / icon / state / loaders / actions).
783
+ // Config is widget-only — `widgetToManifest` layers it on; pages carry none.
784
+ function surfaceToManifest(s: Page): SurfaceManifest {
785
+ const manifest: SurfaceManifest = { title: s.title, icon: s.icon };
786
+ if (s.stateSchema) manifest.stateSchema = toJsonSchemaSafe(s.stateSchema);
787
+ if (s.loaders) {
788
+ manifest.loaders = {};
789
+ for (const [k, l] of Object.entries(s.loaders)) {
790
+ manifest.loaders[k] = loaderToManifest(l);
791
+ }
792
+ }
793
+ if (s.actions) {
794
+ manifest.actions = {};
795
+ for (const [k, a] of Object.entries(s.actions)) {
796
+ manifest.actions[k] = actionToManifest(a);
797
+ }
798
+ }
799
+ return manifest;
800
+ }
801
+
802
+ function widgetToManifest(w: Widget): WidgetManifest {
803
+ const manifest: WidgetManifest = {
804
+ ...surfaceToManifest(w),
805
+ cols: w.cols ?? 2,
806
+ rows: w.rows ?? 2,
807
+ };
808
+ if (w.configSchema) {
809
+ const { fields, defaultConfig, validation } = describeSurfaceConfig(
810
+ w.configSchema as Record<string, Node>,
811
+ );
812
+ manifest.configFields = fields;
813
+ manifest.configSchema = toJsonSchemaSafe(validation);
814
+ // Ship the prefill defaults to the iframe as the base layer; Home merges the
815
+ // placement's persisted config over it before running the surface's loaders
816
+ // (both in the prefill VM and, client-side, in the runtime).
817
+ manifest.defaultConfig = defaultConfig;
818
+ }
819
+ return manifest;
820
+ }
821
+
822
+ function buildUiManifest(ui: UiConfig): UiManifest {
823
+ const manifest: UiManifest = {};
824
+ if (ui.pages) {
825
+ manifest.pages = {};
826
+ for (const [k, p] of Object.entries(ui.pages)) {
827
+ // Only widgets are configurable — a page has no config screen, so a config
828
+ // it could never collect would leave its loaders/render with undefined
829
+ // values the author's types say are present. Fail loud (covers the
830
+ // packed/JS path that skips the type-level rejection on `page()`).
831
+ if ((p as { configSchema?: unknown }).configSchema) {
832
+ throw new Error(
833
+ `ui page "${k}": only widgets can be configured — ` +
834
+ `move the configSchema to a widget or drop it.`,
835
+ );
836
+ }
837
+ manifest.pages[k] = surfaceToManifest(p);
838
+ }
839
+ }
840
+ if (ui.widgets) {
841
+ manifest.widgets = {};
842
+ for (const [k, w] of Object.entries(ui.widgets)) {
843
+ manifest.widgets[k] = widgetToManifest(w);
844
+ }
845
+ }
846
+ return manifest;
847
+ }
848
+
849
+ // Shared factory body. `createApp` / `createSystemApp` are thin typed
850
+ // wrappers that pin `kind`; everything else — schema validation, ui
851
+ // manifest building, the evaluate closure — is identical.
852
+ function buildApp<TSchema extends ConfigSchema>(
853
+ kind: "single" | "system",
854
+ options: SingleAppOptions<TSchema>,
855
+ ): AppDefinition<TSchema> {
856
+ const schema = options.configSchema as Record<string, Node> | undefined;
857
+ if (schema) {
858
+ assertValidKeys(schema);
859
+ assertSchemaHomogeneous(schema);
860
+ // Not all permissions are legal on both factories. Home's graph verification
861
+ // runs the same check authoritatively; this one is so the author sees it from
862
+ // `realm-sdk validate` or the test harness rather than through Home's
863
+ // redacted "app evaluation failed".
864
+ const scopeViolations = checkPermissionAppKinds(kind, collectPermissionNodes(schema));
865
+ if (scopeViolations.length > 0) {
866
+ const factory = kind === "system" ? "createSystemApp" : "createApp";
867
+ throw new Error(`${factory}: ${scopeViolations.map((v) => v.description).join("; ")}`);
868
+ }
869
+ }
870
+ if (options.info && kind === "system") {
871
+ throw new Error(
872
+ `createSystemApp: \`info\` is not supported — a fanned-out service has no ` +
873
+ `single URL for services.<name> to resolve to`,
874
+ );
875
+ }
876
+ if (options.ui) {
877
+ assertValidUi(options.ui);
878
+ assertMergeMatchesKind(options.ui, kind);
879
+ }
880
+ // `ui` + `uiManifest` are mutable so file-based surfaces can attach after
881
+ // construction (see attachSurfaces); inline-ui apps never call it.
882
+ let uiManifest = options.ui ? buildUiManifest(options.ui) : undefined;
883
+ const app: AppDefinition<TSchema> = {
884
+ name: options.name,
885
+ description: options.description,
886
+ kind,
887
+ verifyAnonymousRequest: options.verifyAnonymousRequest,
888
+ configSchema: options.configSchema,
889
+ ui: options.ui,
890
+ info: options.info,
891
+ attachSurfaces(ui: UiConfig) {
892
+ if (app.ui) {
893
+ throw new Error(
894
+ "createApp: `ui` is already set — remove the inline `ui:` block to use " +
895
+ "file-based surfaces (pages/*, widgets/*), or delete the surface files.",
896
+ );
897
+ }
898
+ assertValidUi(ui);
899
+ assertMergeMatchesKind(ui, kind);
900
+ app.ui = ui;
901
+ uiManifest = buildUiManifest(ui);
902
+ },
903
+ evaluate({ config, appId, preview }) {
904
+ const shaped = schema
905
+ ? (deepMergeConfig(
906
+ buildConfigShape(schema),
907
+ (config ?? {}) as Record<string, unknown>,
908
+ ) as InferConfigSchema<TSchema>)
909
+ : config;
910
+ const state = options.getDesiredState({ config: shaped, preview: preview ?? null });
911
+ const withExtras = {
912
+ ...state,
913
+ ...(options.getTrustedHeaders
914
+ ? { trustedHeaders: sampleTrustedHeaders(options.getTrustedHeaders) }
915
+ : {}),
916
+ ...(options.verifyAnonymousRequest ? { hasAnonymousGate: true } : {}),
917
+ ...(uiManifest ? { ui: uiManifest } : {}),
918
+ };
919
+ const resolved = resolveDesiredApp({ ...withExtras, kind }, { appId });
920
+ // The types already make routes unrepresentable on a system app; this
921
+ // catches what they can't — a Service class instance smuggling a router
922
+ // (flattened at resolve time) and raw trustedHeaders / hasAnonymousGate
923
+ // keys returned straight from getDesiredState. Home's graph verification
924
+ // runs the same check authoritatively.
925
+ if (kind === "system") {
926
+ const violations = checkSystemAppRoutes(resolved);
927
+ if (violations.length > 0) {
928
+ throw new Error(violations.map((v) => v.description).join("; "));
929
+ }
930
+ }
931
+ return resolved;
932
+ },
933
+ };
934
+ return app;
935
+ }
936
+
937
+ /**
938
+ * Define a realm.ts app that runs on one operator-selected worker.
939
+ *
940
+ * Returns an `AppDefinition` Home can compile. The author surface is
941
+ * `name`, `description`, `configSchema`, `ui`, and `getDesiredState`;
942
+ * everything downstream (validation, permission checks, ref resolution,
943
+ * compose rendering, iframe bundling) is the SDK's job.
944
+ *
945
+ * @see https://yourrealm.eu/docs/realm-ts/
946
+ */
947
+ export function createApp<TSchema extends ConfigSchema>(
948
+ options: SingleAppOptions<TSchema>,
949
+ ): AppDefinition<TSchema> {
950
+ return buildApp("single", options);
951
+ }
952
+
953
+ /**
954
+ * Define a realm.ts system app: fleet-wide infrastructure that runs on
955
+ * every compatible worker (the seeded backup / firewall / logger / stats apps
956
+ * are system apps). The operator gets no per-worker choice, UI
957
+ * loaders fan out to every reachable worker (`merge` required on each),
958
+ * and the route-bound options — `info`, `getTrustedHeaders`,
959
+ * `verifyAnonymousRequest`, `router` on a service — are not available:
960
+ * system apps have no routes.
961
+ *
962
+ * @see https://yourrealm.eu/docs/realm-ts/workers/
963
+ */
964
+ export function createSystemApp<TSchema extends ConfigSchema>(
965
+ options: SystemAppOptions<TSchema>,
966
+ ): AppDefinition<TSchema> {
967
+ // Runtime backstops for the packed/JS path, which skips the type-level
968
+ // `never` tombstones (precedent: the `info` throw in buildApp).
969
+ if ((options as { getTrustedHeaders?: unknown }).getTrustedHeaders) {
970
+ throw new Error(
971
+ "createSystemApp: `getTrustedHeaders` is not supported — header brokering " +
972
+ "happens on an authenticated subdomain, and a system app has no routes",
973
+ );
974
+ }
975
+ if ((options as { verifyAnonymousRequest?: unknown }).verifyAnonymousRequest) {
976
+ throw new Error(
977
+ "createSystemApp: `verifyAnonymousRequest` is not supported — anonymous " +
978
+ "gates admit callers to an app's routes, and a system app has none",
979
+ );
980
+ }
981
+ return buildApp("system", options);
982
+ }