@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
package/src/resolve.ts ADDED
@@ -0,0 +1,471 @@
1
+ import { z } from "zod";
2
+ import { appIconSchema } from "./app-icon.ts";
3
+ import { nameSchema } from "./identifiers.ts";
4
+ import type { BuildRecipe, ServiceDefinition } from "./service-definition.ts";
5
+ import { buildRecipeSchema } from "./schema/build-apps.ts";
6
+ import { publicDomainValueSchema } from "./schema/public-domain.ts";
7
+ import { Service } from "./service.ts";
8
+
9
+ // Mirrors `ServiceDefinition` field-for-field. `Ref<string>` survives the
10
+ // sandbox boundary as a structured `{__realmRef: ...}` marker the Home
11
+ // walker resolves with a grant-scoped allow-map, so every ref-capable field
12
+ // accepts either a plain string or a marker; the walker replaces markers
13
+ // with strings before compose validation runs.
14
+ //
15
+ // Two structural invariants the schema enforces, so the walker doesn't have
16
+ // to defend against them at every step:
17
+ // 1. `concat` is flat — its parts are strings or non-concat markers.
18
+ // 2. `deferred.input` contains no `deferred` descendants, which makes
19
+ // single-batch `runDeferred` correct by construction.
20
+ const derivedBodySchema = z.object({
21
+ kind: z.literal("derived"),
22
+ appId: z.string(),
23
+ name: z.string(),
24
+ });
25
+ const sharedBodySchema = z.object({
26
+ kind: z.literal("shared"),
27
+ providerAppId: z.string(),
28
+ serviceKey: z.string(),
29
+ name: z.string(),
30
+ });
31
+ const secretBodySchema = z.object({ kind: z.literal("secret"), path: z.string() });
32
+
33
+ function hasDeferredDescendant(value: unknown): boolean {
34
+ if (value == null || typeof value !== "object") return false;
35
+ if (Array.isArray(value)) return value.some(hasDeferredDescendant);
36
+ if ("__realmRef" in value) {
37
+ const m = (value as { __realmRef: unknown }).__realmRef;
38
+ if (m && typeof m === "object") {
39
+ if ((m as { kind?: string }).kind === "deferred") return true;
40
+ return hasDeferredDescendant(m);
41
+ }
42
+ return false;
43
+ }
44
+ for (const v of Object.values(value as Record<string, unknown>)) {
45
+ if (hasDeferredDescendant(v)) return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ const deferredBodySchema = z.object({
51
+ kind: z.literal("deferred"),
52
+ id: z.number().int().nonnegative(),
53
+ input: z.unknown().refine((v) => !hasDeferredDescendant(v), {
54
+ message: "deferred input must not contain another deferred marker",
55
+ }),
56
+ });
57
+ // Concat parts are sync leaves only. `interpolate` rejects
58
+ // `Deferred<string>` at the type level and again with a runtime guard, so a
59
+ // deferred sub-part can't enter a concat through normal authoring; this
60
+ // schema is the last gate on the way out of the sandbox.
61
+ const concatLeafSchema = z.discriminatedUnion("kind", [
62
+ derivedBodySchema,
63
+ sharedBodySchema,
64
+ secretBodySchema,
65
+ ]);
66
+ const concatBodySchema = z.object({
67
+ kind: z.literal("concat"),
68
+ parts: z.array(z.union([z.string(), concatLeafSchema])),
69
+ });
70
+ const markerBodySchema = z.discriminatedUnion("kind", [
71
+ derivedBodySchema,
72
+ sharedBodySchema,
73
+ secretBodySchema,
74
+ deferredBodySchema,
75
+ concatBodySchema,
76
+ ]);
77
+ const refMarkerSchema = z.object({ __realmRef: markerBodySchema });
78
+ const stringOrRef = z.union([z.string(), refMarkerSchema]);
79
+ // hostPath stays a strict string. `interpolate` collapses to a literal when
80
+ // all parts resolve to strings (the Postgres/Valkey pattern), so a ref that
81
+ // survives here is rejected at schema parse - it would leak a marker object
82
+ // where a gate-checked path is expected.
83
+ const hostPathSchema = z
84
+ .string()
85
+ .refine((p) => (p.startsWith("./") || p.startsWith("/")) && !p.includes(".."), {
86
+ message: "hostPath must start with ./ or / and must not contain ..",
87
+ });
88
+
89
+ // Realm internals + SSH. Short on purpose - users want 80, 443, 53, etc.
90
+ const RESERVED_PORTS = new Set([
91
+ 22, // SSH on the worker host
92
+ 3100, // Home
93
+ 3101, // Home's dev uplink listener
94
+ 6021, // The worker's verify relay
95
+ ]);
96
+
97
+ const hostPortNumberSchema = z
98
+ .number()
99
+ .int()
100
+ .min(1, "hostPort must be >= 1")
101
+ .max(65535, "hostPort must be <= 65535")
102
+ .refine((p) => !RESERVED_PORTS.has(p), {
103
+ message: `hostPort must not be a Realm-reserved port (${[...RESERVED_PORTS].join(", ")})`,
104
+ });
105
+
106
+ // Docker reference charset only (lowercase per OCI name grammar). Catches
107
+ // newline (YAML break), `$` (compose env interp), backtick / `;|&<>` (shell
108
+ // metachars), whitespace.
109
+ const imageReferenceSchema = z
110
+ .string()
111
+ .min(1, "image must not be empty")
112
+ .regex(
113
+ /^[a-z0-9_./\-:@]+$/,
114
+ "image must contain only Docker reference characters (a-z 0-9 _ . - / : @)",
115
+ );
116
+
117
+ // Whole-second duration. Stays a number through resolve + the wire;
118
+ // the renderer formats `${n}s` when emitting compose, since the
119
+ // compose-spec duration parser rejects bare integers.
120
+ const duration = (defaultSeconds: number) => z.number().int().nonnegative().default(defaultSeconds);
121
+
122
+ const healthcheckSchema = z.object({
123
+ test: z.array(z.string()),
124
+ interval: duration(3),
125
+ timeout: duration(5),
126
+ retries: z.number().int().nonnegative().default(10),
127
+ startPeriod: duration(10),
128
+ });
129
+
130
+ const containerPortSchema = z.number().int().min(1).max(65535);
131
+
132
+ const routerSchema = z.object({
133
+ name: z.string().min(1).optional(),
134
+ icon: appIconSchema.optional(),
135
+ // Home-screen tile opt-out. Absent = shown; `false` keeps the gated route
136
+ // but drops the tile (machine-facing endpoints like webhooks/registries).
137
+ // Optional rather than `.default(true)` so only opted-out routers carry
138
+ // the field in resolved state.
139
+ tile: z.boolean().optional(),
140
+ containerPort: containerPortSchema,
141
+ // The whole resolved `r.home.publicDomain()` value, hosts and redirect mode
142
+ // together. No `stringOrRef` arm: that would let a bare author-supplied
143
+ // string in through the back door, which is what the permission closes.
144
+ // Absent when the app declares no such permission or none was granted.
145
+ publicDomain: publicDomainValueSchema.optional(),
146
+ pathPrefix: z
147
+ .string()
148
+ .regex(/^\/[a-zA-Z0-9/_-]*$/, "pathPrefix must start with / and contain only [a-zA-Z0-9/_-]")
149
+ .optional(),
150
+ compress: z.boolean().default(true),
151
+ maxRequestSizeMb: z.number().nullable().default(4),
152
+ rateLimit: z
153
+ .union([
154
+ z.number(),
155
+ z.object({
156
+ requests: z.number(),
157
+ window: z.string().regex(/^\d+(ms|s|m|h)$/, "window must be a duration like 10s, 1m, 1h"),
158
+ }),
159
+ ])
160
+ .nullable()
161
+ // A runaway-client guard, not a browsing budget: one page load is ~80
162
+ // requests at the median and ~185 at p90 (HTTP Archive 2025), and grids
163
+ // like Immich or Plex fetch a few hundred thumbnails in a second. 100/s
164
+ // sustained from one address still catches a broken fetch loop; anything
165
+ // tighter (a public login form) is a per-route decision for the author.
166
+ .default({ requests: 1000, window: "10s" }),
167
+ headers: z.record(z.string(), z.string().nullable()).optional(),
168
+ });
169
+
170
+ const hostPortSchema = z.object({
171
+ containerPort: containerPortSchema,
172
+ hostPort: hostPortNumberSchema,
173
+ protocol: z.enum(["tcp", "udp"]).optional(),
174
+ });
175
+
176
+ const backupStrategySchema = z.discriminatedUnion("kind", [
177
+ z.object({ kind: z.literal("file-copy") }),
178
+ z.object({ kind: z.literal("sqlite"), file: z.string() }),
179
+ z.object({
180
+ kind: z.literal("pg-dump"),
181
+ url: stringOrRef,
182
+ version: z.number().int().positive(),
183
+ }),
184
+ z.object({ kind: z.literal("valkey-save"), url: stringOrRef }),
185
+ z.object({
186
+ kind: z.literal("mysql-dump"),
187
+ url: stringOrRef,
188
+ version: z.number().int().positive(),
189
+ }),
190
+ ]);
191
+
192
+ const absoluteContainerPath = z.string().startsWith("/", {
193
+ message: "containerPath must be absolute (start with '/')",
194
+ });
195
+
196
+ const homeServiceSchema = z
197
+ .object({
198
+ // Exactly one of `image` / `build` is present (enforced in the
199
+ // superRefine below). `image` is the prebuilt-pull path; `build` is the
200
+ // build-from-source path, which has no pinned digest until its first
201
+ // build, so Home leaves the app inert until one is set.
202
+ image: imageReferenceSchema.optional(),
203
+ // `build: true` is author sugar for the default recipe — normalize it to
204
+ // `{}` here so `HomeService.build` is always a `BuildRecipe` object.
205
+ // The image/build XOR (superRefine below) reads the transformed value, so
206
+ // `true`/`{}` both count as "build present".
207
+ build: z
208
+ .literal(true)
209
+ .or(buildRecipeSchema)
210
+ .transform((v): BuildRecipe => (v === true ? {} : v))
211
+ .optional(),
212
+ router: routerSchema.optional(),
213
+ hostPorts: z.array(hostPortSchema).optional(),
214
+ env: z.record(z.string(), z.union([stringOrRef, z.number()])).optional(),
215
+ files: z.record(absoluteContainerPath, stringOrRef).optional(),
216
+ command: z.array(stringOrRef).optional(),
217
+ volumes: z
218
+ .array(
219
+ z.object({
220
+ hostPath: hostPathSchema,
221
+ containerPath: absoluteContainerPath,
222
+ // Silo-owned group. Present only on a Silo's volume entry; the
223
+ // renderer derives `group_add` from it and the grant gate matches
224
+ // (hostPath, gid) against the Silos the app joined.
225
+ gid: z.number().int().positive().optional(),
226
+ readOnly: z.boolean().optional(),
227
+ backup: z.union([backupStrategySchema, z.literal(false)]).optional(),
228
+ }),
229
+ )
230
+ .optional(),
231
+ gpu: z.boolean().optional(),
232
+ iptables: z.boolean().optional(),
233
+ joinNetworks: z.array(z.string()).optional(),
234
+ healthcheck: healthcheckSchema.optional(),
235
+ memory: z.string().optional(),
236
+ cpu: z.number().positive().max(128).optional(),
237
+ replicas: z.number().int().min(1).max(100).default(1),
238
+ labels: z.array(z.string()).optional(),
239
+ shmSizeMb: z.number().int().positive().optional(),
240
+ })
241
+ .strict()
242
+ .superRefine((svc, ctx) => {
243
+ // Exactly one of `image` / `build`. The type-level XOR on
244
+ // ServiceDefinition catches this at author time; this is the runtime
245
+ // backstop for sandbox-produced state, where the types are erased.
246
+ if (svc.image == null && svc.build == null) {
247
+ ctx.addIssue({
248
+ code: "custom",
249
+ path: ["image"],
250
+ message: "a service must set either `image` or `build`",
251
+ });
252
+ } else if (svc.image != null && svc.build != null) {
253
+ ctx.addIssue({
254
+ code: "custom",
255
+ path: ["build"],
256
+ message: "a service cannot set both `image` and `build`",
257
+ });
258
+ }
259
+
260
+ // Two volumes on one containerPath hit the same "duplicate mount point"
261
+ // failure as the files/volumes collision below. Reachable by mounting one
262
+ // resolved value twice, or alongside a hand-written path matching it.
263
+ const seen = new Set<string>();
264
+ for (const [i, vol] of (svc.volumes ?? []).entries()) {
265
+ if (seen.has(vol.containerPath)) {
266
+ ctx.addIssue({
267
+ code: "custom",
268
+ path: ["volumes", i, "containerPath"],
269
+ message: `containerPath ${vol.containerPath} is mounted by two volumes`,
270
+ });
271
+ }
272
+ seen.add(vol.containerPath);
273
+ }
274
+
275
+ // `files:` and `volumes:` both emit bind mounts in the rendered
276
+ // compose. A collision on containerPath produces a "duplicate mount
277
+ // point" error at `docker compose up`, far from the source. Reject
278
+ // here so the author sees the conflict at validation time.
279
+ if (!svc.files || !svc.volumes) return;
280
+ const volumePaths = new Set(svc.volumes.map((v) => v.containerPath));
281
+ for (const fileKey of Object.keys(svc.files)) {
282
+ if (volumePaths.has(fileKey)) {
283
+ ctx.addIssue({
284
+ code: "custom",
285
+ path: ["files", fileKey],
286
+ message: `containerPath ${fileKey} is used by both files: and volumes:`,
287
+ });
288
+ }
289
+ }
290
+ });
291
+
292
+ export type HomeService = z.infer<typeof homeServiceSchema>;
293
+
294
+ // ui surface manifest. Output/input schemas are emitted as JSON Schema
295
+ // (z.toJSONSchema) so they survive the sandbox boundary; headers can carry
296
+ // refs (e.g. r.stableSecret) the walker resolves under the app's grant
297
+ // scope. `service`/`port` are the grant boundary (which container), so they
298
+ // are flat and static here; the per-call verb/path/query/body comes from the
299
+ // loader's `request(state, config)` fn in the iframe.
300
+
301
+ // The request descriptor a loader's `request(state, config)` returns. Not a
302
+ // manifest field — Home builds these live at prefill time (stitchLoaderRequests)
303
+ // and the iframe runtime builds one per render; both feed it to the proxy.
304
+ const requestManifestSchema = z.object({
305
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
306
+ path: z.string(),
307
+ query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
308
+ body: z.unknown().optional(),
309
+ });
310
+
311
+ const loaderManifestSchema = z.object({
312
+ service: z.string(),
313
+ port: z.number().int().positive().max(65535),
314
+ headers: z.record(z.string(), stringOrRef).optional(),
315
+ outputSchema: z.unknown(),
316
+ merge: z.literal(true).optional(),
317
+ });
318
+
319
+ // Actions are mutations — `outputSchema` is optional (authors often don't
320
+ // care about the response). The caller passes an explicit input at
321
+ // `mutate(input)` time, so actions keep an `inputSchema` where loaders read
322
+ // the widget's state. No `merge` knob: a system app resolves with the raw
323
+ // `{ results, failures }` envelope, a single app with the upstream body.
324
+ // Actions are never prefilled; the request is built at call time.
325
+ const actionManifestSchema = z.object({
326
+ service: z.string(),
327
+ port: z.number().int().positive().max(65535),
328
+ headers: z.record(z.string(), stringOrRef).optional(),
329
+ inputSchema: z.unknown(),
330
+ outputSchema: z.unknown().optional(),
331
+ });
332
+
333
+ const baseSurfaceManifestSchema = z.object({
334
+ title: z.string().min(1),
335
+ // Icon name from `@yourrealm/sdk/ui/components/icons.tsx` named exports.
336
+ // Runtime is just `string` (the TS-side `IconName` union catches
337
+ // typos at authoring time); the Home sidebar falls back to a default
338
+ // glyph if a value isn't a known export, so a typo doesn't 500.
339
+ icon: z.string().min(1),
340
+ // The surface's live UI state + settings schemas (JSON Schema form).
341
+ // `configSchema` (built from the config leaves' zod schemas) lets Home
342
+ // validate a submitted config; `stateSchema`'s `{}`-parse seeds the
343
+ // runtime's initial state.
344
+ stateSchema: z.unknown().optional(),
345
+ configSchema: z.unknown().optional(),
346
+ // The config form's render descriptor: one `ConfigFieldLeaf` per
347
+ // `r.input.*` leaf (type/label/placeholder/default/enum values/bounds),
348
+ // so the tile's config screen reuses the same field renderer the app
349
+ // install/edit form uses. Absent when the surface takes no config.
350
+ configFields: z.unknown().optional(),
351
+ // The surface's resolved config: each leaf's `.prefill()` default,
352
+ // shipped to the iframe as the base layer for every loader's
353
+ // request(state, config) (Home merges the placement's persisted config
354
+ // over it).
355
+ defaultConfig: z.unknown().optional(),
356
+ loaders: z.record(z.string(), loaderManifestSchema).optional(),
357
+ actions: z.record(z.string(), actionManifestSchema).optional(),
358
+ });
359
+
360
+ // Widget-only tile size in 8rem grid cells. Defaults materialize here
361
+ // so every persisted widget entry is self-describing — DB rows carry
362
+ // the actual `cols`/`rows`, no consumer-side `?? 2` fallbacks needed.
363
+ // Mobile collapse formula lives in `home-grid.tsx`.
364
+ const widgetManifestSchema = baseSurfaceManifestSchema.extend({
365
+ cols: z.number().int().min(1).default(2),
366
+ rows: z.number().int().min(1).default(2),
367
+ });
368
+
369
+ const uiManifestSchema = z.object({
370
+ pages: z.record(z.string(), baseSurfaceManifestSchema).optional(),
371
+ widgets: z.record(z.string(), widgetManifestSchema).optional(),
372
+ });
373
+
374
+ // Wire-side TS types inferred from the schemas above; consumers outside the
375
+ // SDK reach them via the `/compile` barrel re-export. Author-facing types
376
+ // (`Page`, `Widget`, `Loader`, `UiConfig`) live in `ui-surface.ts` because
377
+ // they carry render fns and live Zod schemas that can't survive a JSON
378
+ // round-trip.
379
+ export type LoaderManifest = z.infer<typeof loaderManifestSchema>;
380
+ export type ActionManifest = z.infer<typeof actionManifestSchema>;
381
+ export type RequestManifest = z.infer<typeof requestManifestSchema>;
382
+ export type SurfaceManifest = z.infer<typeof baseSurfaceManifestSchema>;
383
+ export type WidgetManifest = z.infer<typeof widgetManifestSchema>;
384
+ export type UiManifest = z.infer<typeof uiManifestSchema>;
385
+ export type ManifestHeader = z.infer<typeof stringOrRef>;
386
+
387
+ // Header brokering manifest, sampled from `getTrustedHeaders` at eval time.
388
+ // `keys` is the static union of header names across roles (Caddy injects
389
+ // these and anti-spoof strips them). `byRole` maps each header name to its
390
+ // source: a `user` field, a literal string, or a `template` of literal +
391
+ // field segments (when the author interpolates, e.g. `i`Bearer
392
+ // ${user.username}``). The verify hop substitutes the live value.
393
+ const headerFieldSchema = z.object({ field: z.enum(["username", "email"]) });
394
+ const headerSegmentSchema = z.union([z.string(), headerFieldSchema]);
395
+ const authHeaderSourceSchema = z.union([
396
+ headerFieldSchema,
397
+ z.object({ literal: z.string() }),
398
+ z.object({ template: z.array(headerSegmentSchema) }),
399
+ ]);
400
+ const trustedHeadersManifestSchema = z.object({
401
+ keys: z.array(z.string()),
402
+ byRole: z.record(z.enum(["admin", "member"]), z.record(z.string(), authHeaderSourceSchema)),
403
+ });
404
+
405
+ export type TrustedHeaderSource = z.infer<typeof authHeaderSourceSchema>;
406
+ export type TrustedHeadersManifest = z.infer<typeof trustedHeadersManifestSchema>;
407
+
408
+ // The resolved form of an app's `DesiredApp` (what `getDesiredState`
409
+ // returns): services + egress + the ui / auth / routing manifests, after
410
+ // `resolveDesiredApp()` flattens Service instances to definitions and the
411
+ // Home walker bakes refs to literals. Home's internal per-app model — it
412
+ // lives as `AppEval.app` and is what `build-wire` projects the worker wire
413
+ // (`WorkerApp`) out of, dropping the Home-only fields (`ui`,
414
+ // `trustedHeaders`, `hasAnonymousGate`, `kind`).
415
+ export const homeAppSchema = z.object({
416
+ services: z.record(nameSchema, homeServiceSchema),
417
+ egress: z.literal(true).optional(),
418
+ ui: uiManifestSchema.optional(),
419
+ trustedHeaders: trustedHeadersManifestSchema.optional(),
420
+ // True when the app declares verifyAnonymousRequest. Home-side only (never
421
+ // crosses to the worker wire); recompute reads it to register the app's
422
+ // routed hosts in the anonymous-gate cache.
423
+ hasAnonymousGate: z.boolean().optional(),
424
+ // Single = run on one selected worker (default). System = fan out to
425
+ // every compatible worker. Drives the install panel's picker and the
426
+ // Home's UI fan-out behavior. See platform/docs realm-ts/workers.mdx.
427
+ kind: z.enum(["single", "system"]).default("single"),
428
+ });
429
+
430
+ export type HomeApp = z.infer<typeof homeAppSchema>;
431
+
432
+ /**
433
+ * Scans the services map for Service instances and assigns service keys.
434
+ * After this, refs resolve (e.g. db.url becomes the real string).
435
+ *
436
+ * Returns the resolved desired state — Service instances are replaced
437
+ * with their input definitions for the Worker.
438
+ *
439
+ * `opts.appId`, when provided, namespaces each Service's key as
440
+ * `${appId}_${serviceKey}` so `host` refs resolve to the DNS name a
441
+ * sibling container will see in the merged worker compose project.
442
+ */
443
+ export function resolveDesiredApp(
444
+ state: {
445
+ services?: Record<string, Service | ServiceDefinition>;
446
+ egress?: true;
447
+ ui?: UiManifest;
448
+ trustedHeaders?: TrustedHeadersManifest;
449
+ hasAnonymousGate?: boolean;
450
+ kind?: "single" | "system";
451
+ },
452
+ opts: { appId?: string } = {},
453
+ ): HomeApp {
454
+ const resolvedServices: Record<string, unknown> = {};
455
+
456
+ for (const [key, service] of Object.entries(state.services ?? {})) {
457
+ if (service instanceof Service) {
458
+ const qualifiedKey = opts.appId ? `${opts.appId}_${key}` : key;
459
+ service._setServiceKey(qualifiedKey);
460
+ resolvedServices[key] = service.getServiceDefinition();
461
+ } else {
462
+ resolvedServices[key] = service;
463
+ }
464
+ }
465
+
466
+ // Re-serialize to resolve all refs (`JSON.stringify` materializes ref
467
+ // proxies via their `toJSON`), then validate. Round-trip is a deep
468
+ // clone of data we just constructed; no untrusted input.
469
+ const serialized = JSON.parse(JSON.stringify({ ...state, services: resolvedServices }));
470
+ return homeAppSchema.parse(serialized);
471
+ }
@@ -0,0 +1,118 @@
1
+ // Validation schema for the docker-compose YAML the renderer produces.
2
+ // Used by Home integration tests to assert the generated output round-trips
3
+ // against the compose grammar Realm actually emits — strict so a stray
4
+ // field surfaces as a test failure rather than a silent passthrough.
5
+
6
+ import { z } from "zod";
7
+
8
+ const volumeSchema = z
9
+ .object({
10
+ type: z.enum(["bind", "volume"]),
11
+ source: z.string(),
12
+ target: z.string(),
13
+ read_only: z.boolean().optional(),
14
+ })
15
+ .strict();
16
+
17
+ const portSchema = z
18
+ .object({
19
+ target: z.number().int(),
20
+ published: z.number().int(),
21
+ protocol: z.enum(["tcp", "udp"]).optional(),
22
+ })
23
+ .strict();
24
+
25
+ const healthcheckSchema = z
26
+ .object({
27
+ test: z.array(z.string()),
28
+ interval: z.string().optional(),
29
+ timeout: z.string().optional(),
30
+ retries: z.number().int().optional(),
31
+ start_period: z.string().optional(),
32
+ })
33
+ .strict();
34
+
35
+ const deploySchema = z
36
+ .object({
37
+ replicas: z.number().int().optional(),
38
+ resources: z
39
+ .object({
40
+ limits: z
41
+ .object({
42
+ memory: z.string().optional(),
43
+ cpus: z.string().optional(),
44
+ })
45
+ .strict()
46
+ .optional(),
47
+ })
48
+ .strict()
49
+ .optional(),
50
+ })
51
+ .strict();
52
+
53
+ const dependsOnEntrySchema = z
54
+ .object({
55
+ condition: z.enum(["service_healthy", "service_started", "service_completed_successfully"]),
56
+ restart: z.boolean().optional(),
57
+ })
58
+ .strict();
59
+
60
+ const loggingSchema = z
61
+ .object({
62
+ driver: z.string(),
63
+ options: z.record(z.string(), z.string()).optional(),
64
+ })
65
+ .strict();
66
+
67
+ const composeServiceSchema = z
68
+ .object({
69
+ image: z.string(),
70
+ environment: z.record(z.string(), z.string()).optional(),
71
+ env_file: z.union([z.string(), z.array(z.string())]).optional(),
72
+ command: z.array(z.string()).optional(),
73
+ labels: z.record(z.string(), z.string()).optional(),
74
+ volumes: z.array(volumeSchema).optional(),
75
+ group_add: z.array(z.number().int().positive()).optional(),
76
+ ports: z.array(portSchema).optional(),
77
+ healthcheck: healthcheckSchema.optional(),
78
+ deploy: deploySchema.optional(),
79
+ cap_drop: z.array(z.string()).optional(),
80
+ cap_add: z.array(z.string()).optional(),
81
+ security_opt: z.array(z.string()).optional(),
82
+ pids_limit: z.number().int().optional(),
83
+ ulimits: z.record(z.string(), z.number().int()).optional(),
84
+ shm_size: z.string().optional(),
85
+ devices: z.array(z.string()).optional(),
86
+ network_mode: z.string().optional(),
87
+ restart: z.string().optional(),
88
+ stop_grace_period: z.string().optional(),
89
+ depends_on: z.record(z.string(), dependsOnEntrySchema).optional(),
90
+ networks: z
91
+ .record(
92
+ z.string(),
93
+ z
94
+ .object({
95
+ aliases: z.array(z.string()).optional(),
96
+ })
97
+ .strict(),
98
+ )
99
+ .optional(),
100
+ logging: loggingSchema.optional(),
101
+ })
102
+ .strict();
103
+
104
+ const composeNetworkSchema = z
105
+ .object({
106
+ name: z.string().optional(),
107
+ external: z.boolean().optional(),
108
+ internal: z.boolean().optional(),
109
+ driver: z.string().optional(),
110
+ })
111
+ .strict();
112
+
113
+ export const composeSchema = z
114
+ .object({
115
+ services: z.record(z.string(), composeServiceSchema),
116
+ networks: z.record(z.string(), composeNetworkSchema).optional(),
117
+ })
118
+ .strict();