@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,594 @@
1
+ // Home-side security primitives: the loose outer-shape parser for a stored
2
+ // grants blob (`grantsSchema`), the strict write-time filter (`sanitizeGrants`),
3
+ // and the gates that check a resolved app's elevated uses against its grants.
4
+ // `HOST_DIRECTORY_BLOCKLIST` / `isValidHostDirectoryPath` are re-exported from
5
+ // schema/host.ts, which owns them to avoid an import cycle.
6
+
7
+ import { z } from "zod";
8
+ import type { Network } from "./identifiers.ts";
9
+ import type { HomeApp } from "./resolve.ts";
10
+ import type { Grant, Grants } from "./grants.ts";
11
+ import {
12
+ collectUses,
13
+ homeHooks,
14
+ dataMatches,
15
+ hostHooks,
16
+ isHomeGrant,
17
+ isHostGrant,
18
+ } from "./grants.ts";
19
+ import { PERMISSION_META, permissionAllowsAppKind } from "./permissions.ts";
20
+ import type { AppKind, PermissionKind, WorkerCapability } from "./permissions.ts";
21
+ import type { SerializablePermission } from "./sandbox.ts";
22
+
23
+ export {
24
+ HOST_DIRECTORY_BLOCKLIST,
25
+ isValidHostDirectoryPath,
26
+ normalizeHostPath,
27
+ pathsOverlap,
28
+ } from "./schema/host.ts";
29
+
30
+ /**
31
+ * Loose outer-shape parser for the JSON-stored grants blob. Per-kind data
32
+ * validation runs in sanitizeGrants — keeping the schema parse loose lets
33
+ * us land readable error messages for per-kind issues there instead of
34
+ * burying them inside Zod's superRefine machinery.
35
+ */
36
+ const rawGrantSchema = z.object({
37
+ kind: z.string(),
38
+ data: z.unknown().optional(),
39
+ }) as z.ZodType<Grant>;
40
+
41
+ export const grantsSchema: z.ZodType<Grants> = z.record(z.string(), rawGrantSchema);
42
+
43
+ /**
44
+ * Per-grant rejection/prune entry. `path` is the grants-blob key (the
45
+ * permission descriptor path), `kind` is the kind the admin sent (helps
46
+ * the UI show which control failed), `reason` is a human-readable
47
+ * explanation suitable for surfacing to the operator.
48
+ */
49
+ export interface GrantIssue {
50
+ path: string;
51
+ kind: string;
52
+ reason: string;
53
+ }
54
+
55
+ /**
56
+ * Result of {@link sanitizeGrants}. Two distinct buckets:
57
+ *
58
+ * - `rejected` — admin sent grants whose data the per-kind grantSchema
59
+ * refused (an out-of-range port, an unknown kind, etc.) or
60
+ * whose kind isn't recognized. These represent user error and the API
61
+ * must surface them as a 400 instead of silently dropping.
62
+ * - `pruned` — grants whose declaration is missing (orphan path) or whose
63
+ * kind doesn't match the declaration at that path. These represent
64
+ * source drift (the realm.ts changed and removed/renamed a permission),
65
+ * not admin input bugs, so the API logs them and proceeds.
66
+ */
67
+ export interface SanitizeResult {
68
+ sanitized: Grants;
69
+ rejected: GrantIssue[];
70
+ pruned: GrantIssue[];
71
+ }
72
+
73
+ /**
74
+ * Validate one admin-supplied grant's data shape against the per-kind
75
+ * grantSchema. Returns the (possibly narrowed) grant on success, a
76
+ * reason string on rejection. The caller decides whether the rejection
77
+ * is a hard error (bad admin input) or a soft prune (source drift) —
78
+ * this function only checks the per-kind data shape.
79
+ */
80
+ function validateGrantData(
81
+ grant: Grant,
82
+ ): { ok: true; grant: Grant } | { ok: false; reason: string } {
83
+ if (isHostGrant(grant)) {
84
+ const hooks = hostHooks(grant.kind);
85
+ if (!hooks?.grantSchema) {
86
+ return { ok: false, reason: `unknown host permission kind: ${grant.kind}` };
87
+ }
88
+ const r = hooks.grantSchema.safeParse(grant.data);
89
+ if (!r.success) {
90
+ const reason = r.error.issues.map((i) => i.message).join("; ");
91
+ return { ok: false, reason };
92
+ }
93
+ return { ok: true, grant: { kind: grant.kind, data: r.data } };
94
+ }
95
+ // home.* carries data like host (the granted scopes). Validate against the
96
+ // per-kind grantSchema — an unknown scope lands in the `rejected` bucket.
97
+ if (isHomeGrant(grant)) {
98
+ const hooks = homeHooks(grant.kind);
99
+ if (!hooks?.grantSchema) {
100
+ return { ok: false, reason: `unknown home permission kind: ${grant.kind}` };
101
+ }
102
+ const r = hooks.grantSchema.safeParse(grant.data);
103
+ if (!r.success) {
104
+ const reason = r.error.issues.map((i) => i.message).join("; ");
105
+ return { ok: false, reason };
106
+ }
107
+ return { ok: true, grant: { kind: grant.kind, data: r.data } };
108
+ }
109
+ // peers.* and remote.* are presence-only — the grant is a flag, no
110
+ // data field. The descriptor carries any necessary info (fetchUrl URL)
111
+ // and the runtime resolver pulls real values from world state / fetch.
112
+ if (grant.kind.startsWith("peers.") || grant.kind.startsWith("remote.")) {
113
+ return { ok: true, grant: { kind: grant.kind } };
114
+ }
115
+ return { ok: false, reason: `unknown permission kind: ${grant.kind}` };
116
+ }
117
+
118
+ /**
119
+ * Walk an admin-supplied grants blob against the source's declared
120
+ * permissions. Splits each entry into three buckets — kept (passes both
121
+ * declaration and per-kind schema checks), rejected (admin-input error),
122
+ * or pruned (source drift). Source drives what's installed; grants are
123
+ * narrowing choices, not expansions.
124
+ */
125
+ export function sanitizeGrants(
126
+ permsFlat: Record<string, SerializablePermission>,
127
+ grants: Grants | undefined,
128
+ ): SanitizeResult {
129
+ if (!grants) return { sanitized: {}, rejected: [], pruned: [] };
130
+ const sanitized: Grants = {};
131
+ const rejected: GrantIssue[] = [];
132
+ const pruned: GrantIssue[] = [];
133
+ for (const [path, grant] of Object.entries(grants)) {
134
+ const perm = permsFlat[path];
135
+ if (!perm) {
136
+ pruned.push({ path, kind: grant.kind, reason: "no declared permission at this path" });
137
+ continue;
138
+ }
139
+ if (grant.kind !== perm.kind) {
140
+ pruned.push({
141
+ path,
142
+ kind: grant.kind,
143
+ reason: `kind ${grant.kind} does not match declared ${perm.kind}`,
144
+ });
145
+ continue;
146
+ }
147
+ const result = validateGrantData(grant);
148
+ if (!result.ok) {
149
+ rejected.push({ path, kind: grant.kind, reason: result.reason });
150
+ continue;
151
+ }
152
+ sanitized[path] = result.grant;
153
+ }
154
+ return { sanitized, rejected, pruned };
155
+ }
156
+
157
+ // ============================================================================
158
+ // Violations
159
+ // ============================================================================
160
+
161
+ export interface GrantViolation {
162
+ kind: "grant";
163
+ permissionKind: string;
164
+ /**
165
+ * Compose service the use originated from, omitted for app-level uses
166
+ * (e.g. `state.egress`, which is declared once for the whole app, not
167
+ * per service).
168
+ */
169
+ serviceName?: string;
170
+ use: unknown;
171
+ description: string;
172
+ }
173
+
174
+ export interface EvalErrorViolation {
175
+ kind: "eval";
176
+ message: string;
177
+ }
178
+
179
+ /**
180
+ * Raised when a *pinned* worker (`deployed: <worker id>`, a single app)
181
+ * doesn't advertise one of the app's required host permission kinds.
182
+ * A system app, which stores no placement (`deployed: null`), doesn't
183
+ * produce this violation: install proceeds dormant and the per-worker
184
+ * recompute filter handles the gap as workers come and go.
185
+ */
186
+ export interface WorkerCapabilityViolation {
187
+ kind: "worker-capability";
188
+ /** The worker's slug: what an operator reading the message calls the box. */
189
+ workerId: string;
190
+ missing: WorkerCapability[];
191
+ description: string;
192
+ }
193
+
194
+ /**
195
+ * Raised when something about the app contradicts its declared kind.
196
+ * Three producers, all reported the same way so `logViolations` needs no
197
+ * new branch:
198
+ *
199
+ * - Home's deploy-target check: a single app requires a worker id, the box
200
+ * it runs on. A system app has nothing to check — it stores no
201
+ * placement, which Home derives from the source rather than accepting
202
+ * from the client.
203
+ * - `checkSystemAppRoutes`: routes, header brokering or an anonymous
204
+ * gate on a system app.
205
+ * - `checkPermissionAppKinds`: a permission whose `appKinds` scope
206
+ * excludes this app's kind.
207
+ */
208
+ export interface AppKindViolation {
209
+ kind: "app-kind";
210
+ appKind: "single" | "system";
211
+ description: string;
212
+ }
213
+
214
+ /**
215
+ * Raised when a `router.publicDomain` sits under the Realm `baseDomain`.
216
+ * That namespace is Home's: every routed service already gets an
217
+ * authenticated subdomain there, and the `.{baseDomain}`-scoped Home session
218
+ * cookie reaches anything under it - so an ungated public route in that space
219
+ * would hand the app a live Home session (account-takeover). Public domains
220
+ * must live outside the Realm domain.
221
+ */
222
+ export interface RoutingViolation {
223
+ kind: "routing";
224
+ serviceName: string;
225
+ description: string;
226
+ }
227
+
228
+ export type Violation =
229
+ | GrantViolation
230
+ | EvalErrorViolation
231
+ | WorkerCapabilityViolation
232
+ | AppKindViolation
233
+ | RoutingViolation;
234
+
235
+ interface CheckAppGrantsOptions {
236
+ /**
237
+ * Networks the consumer is allowed to join (e.g. `management-net` from
238
+ * a `host.management` grant, or peer networks from granted peer
239
+ * permissions). A `joinNetworks` entry not in this set produces a
240
+ * `network.join` violation.
241
+ */
242
+ allowedNetworks?: Set<Network>;
243
+ /**
244
+ * Absolute host paths exempted from the absolute-mount gate: paths a
245
+ * resolved permission handed the app (a Silo volume, a broker socket
246
+ * dir, peer backup mounts) — the grant already authorized the mount.
247
+ */
248
+ allowedAbsoluteHostPaths?: Set<string>;
249
+ /**
250
+ * Subset of `allowedAbsoluteHostPaths` the grant only covers read-only,
251
+ * mapped to the permission kind that granted it (so the violation names the
252
+ * real permission, not the mount gate it was exempted from).
253
+ *
254
+ * Two sources. The broker socket dirs (`host.logs`, `host.stats`,
255
+ * `host.spawn`'s socket, `home.apiAccess`'s socket) resolve to
256
+ * `readOnly: true` mounts because connecting to the socket needs no write
257
+ * access to the directory holding it. And a `host.silo` member that declared
258
+ * `volume: "read"` gets the shared directory read-only.
259
+ *
260
+ * Either way an app that mounts one writable gets a violation rather than a
261
+ * silently-corrected mount: rejecting keeps the source honest about what
262
+ * runs.
263
+ */
264
+ readOnlyHostPaths?: Map<string, string>;
265
+ /**
266
+ * Silo volumes this app may mount, keyed hostPath + NUL + gid by
267
+ * `siloMountKey`. A `host.silo` use carries both halves of "same bytes" -
268
+ * which directory, and which group owns it - and both have to match a Silo
269
+ * the operator bound this app to. So a hand-written `gid`, a Silo path
270
+ * without the join, or a gid belonging to a *different* Silo all fail here.
271
+ */
272
+ allowedSiloMounts?: Set<string>;
273
+ }
274
+
275
+ /**
276
+ * Key for `allowedSiloMounts`. NUL-separated: a NUL can't appear in a
277
+ * host path, so the gid half can never be mistaken for part of one.
278
+ */
279
+ export function siloMountKey(hostPath: string, gid: number): string {
280
+ return `${hostPath}\u0000${gid}`;
281
+ }
282
+
283
+ /**
284
+ * Walk every service in a resolved app state and return one violation per
285
+ * elevated thing the app does without a matching grant.
286
+ *
287
+ * Path-aware: a use is only authorized when there's a *declared*
288
+ * permission whose path P satisfies (a) `permsFlat[P].kind === use.kind`
289
+ * AND (b) `grants[P]?.data` matches `use.data`. Hardcoded uses (no
290
+ * declaring permission of that kind anywhere in `permsFlat`) are flagged
291
+ * unconditionally — there is no path through which to grant them.
292
+ * Orphan-keyed grants (a grant at a path with no declaration) cannot
293
+ * authorize anything. The descriptor contract drives the gate, not bare
294
+ * kind+value matching across an unkeyed grants array.
295
+ */
296
+ export function checkAppGrants(
297
+ state: HomeApp,
298
+ grants: Grants,
299
+ permsFlat: Record<string, SerializablePermission>,
300
+ options?: CheckAppGrantsOptions,
301
+ ): GrantViolation[] {
302
+ const violations: GrantViolation[] = [];
303
+ // Index declared permissions by kind so the inner loop is a hash hit
304
+ // instead of an O(n) scan over permsFlat per use.
305
+ const declarationsByKind = new Map<string, Array<[string, SerializablePermission]>>();
306
+ for (const [path, perm] of Object.entries(permsFlat)) {
307
+ const list = declarationsByKind.get(perm.kind) ?? [];
308
+ list.push([path, perm]);
309
+ declarationsByKind.set(perm.kind, list);
310
+ }
311
+
312
+ for (const [serviceName, svc] of Object.entries(state.services)) {
313
+ // A worker-supplied mount whose grant only covers read access. Checked
314
+ // ahead of the use loop because the path is exempt from the
315
+ // absolute-mount gate below — without this, mounting it writable would
316
+ // pass silently.
317
+ for (const v of svc.volumes ?? []) {
318
+ const grantedBy = options?.readOnlyHostPaths?.get(v.hostPath as string);
319
+ if (grantedBy === undefined) continue;
320
+ if (v.readOnly === true) continue;
321
+ violations.push({
322
+ kind: "grant",
323
+ permissionKind: grantedBy,
324
+ serviceName,
325
+ use: v.hostPath,
326
+ description: "write to a directory the grant only covers read-only",
327
+ });
328
+ }
329
+ // The absolute-mount gate. No permission grants a raw absolute path, so
330
+ // any bind mount outside the app's own `./` data dir must have come from
331
+ // a resolved permission's exemption (a Silo volume, a broker socket dir,
332
+ // a peer backup mount). Anything else is a hardcoded host mount — the
333
+ // types don't allow one, but a packed app can hand the VM whatever it
334
+ // wants, so the gate is here, not in the compiler. gid-carrying mounts
335
+ // are judged as `host.silo` uses below instead: the (hostPath, gid) pair
336
+ // has to match a bound Silo.
337
+ for (const v of svc.volumes ?? []) {
338
+ const hostPath = v.hostPath as string;
339
+ if (hostPath.startsWith("./")) continue;
340
+ if (v.gid !== undefined) continue;
341
+ if (options?.allowedAbsoluteHostPaths?.has(hostPath)) continue;
342
+ violations.push({
343
+ kind: "grant",
344
+ permissionKind: "host.mount",
345
+ serviceName,
346
+ use: hostPath,
347
+ description: "mount an absolute host path",
348
+ });
349
+ }
350
+ for (const use of collectUses(svc)) {
351
+ // A Silo mount's grant data is the operator's chosen Silo id, so it can
352
+ // never `dataMatches` the use (which is the resolved directory + group).
353
+ // Home resolved both from the Silo record, so the pair itself is the
354
+ // check: same bytes, same group.
355
+ if (use.kind === "host.silo") {
356
+ const { hostPath, gid } = use.data as { hostPath: string; gid: number };
357
+ if (options?.allowedSiloMounts?.has(siloMountKey(hostPath, gid))) continue;
358
+ violations.push({
359
+ kind: "grant",
360
+ permissionKind: use.kind,
361
+ serviceName,
362
+ use: use.data,
363
+ description: PERMISSION_META["host.silo"].phrase,
364
+ });
365
+ continue;
366
+ }
367
+ // Public domains match by set containment, not `dataMatches`. That
368
+ // comparator is JSON-string equality, so it would turn a reordered
369
+ // `hosts` array or a differently-keyed record into a spurious drop —
370
+ // and a router legitimately serves a subset of what was granted (a
371
+ // second service on the same grant, an app that routes only its
372
+ // canonical host). What must never pass is a host outside the set.
373
+ if (use.kind === "home.publicDomain") {
374
+ const { hosts, domainMode } = use.data as { hosts: string[]; domainMode: string };
375
+ const declarations = declarationsByKind.get(use.kind) ?? [];
376
+ const matched = declarations.some(([path]) => {
377
+ const grant = grants[path];
378
+ if (grant == null || !isHomeGrant(grant) || grant.kind !== use.kind) return false;
379
+ const data = grant.data as { hosts?: string[]; domainMode?: string };
380
+ // `domainMode` is checked, not just carried. It rides inside the
381
+ // value so the author can't wire the hosts and forget it — but a
382
+ // spread (`{ ...config.domain, domainMode: "all" }`) typechecks and
383
+ // would otherwise override the operator's redirect choice silently,
384
+ // which is the exact failure the one-field shape exists to prevent.
385
+ if (data.domainMode !== domainMode) return false;
386
+ const granted = new Set(data.hosts ?? []);
387
+ return hosts.every((h) => granted.has(h));
388
+ });
389
+ if (!matched) {
390
+ violations.push({
391
+ kind: "grant",
392
+ permissionKind: use.kind,
393
+ serviceName,
394
+ use: use.data,
395
+ description: PERMISSION_META["home.publicDomain"].phrase,
396
+ });
397
+ }
398
+ continue;
399
+ }
400
+ const declarations = declarationsByKind.get(use.kind) ?? [];
401
+ const matched = declarations.some(([path]) => {
402
+ const grant = grants[path];
403
+ return (
404
+ grant != null &&
405
+ isHostGrant(grant) &&
406
+ grant.kind === use.kind &&
407
+ dataMatches(grant.data, use.data)
408
+ );
409
+ });
410
+ if (!matched) {
411
+ violations.push({
412
+ kind: "grant",
413
+ permissionKind: use.kind,
414
+ serviceName,
415
+ use: use.data,
416
+ description: PERMISSION_META[use.kind as PermissionKind]?.phrase ?? use.kind,
417
+ });
418
+ }
419
+ }
420
+ for (const net of svc.joinNetworks ?? []) {
421
+ if (!options?.allowedNetworks?.has(net as Network)) {
422
+ violations.push({
423
+ kind: "grant",
424
+ permissionKind: "network.join",
425
+ serviceName,
426
+ use: [net],
427
+ description: "join network",
428
+ });
429
+ }
430
+ }
431
+ }
432
+ // App-level egress gate. `state.egress` lives next to `services` (the
433
+ // realm.ts author writes `egress: config.egress` from a `r.host.egress()`
434
+ // permission). Without this check an app could hardcode `egress: true`
435
+ // and the renderer would drop `internal: true` from the network — full
436
+ // outbound access with no consent flow. Require a declared `host.egress`
437
+ // at some path with a matching grant entry, mirroring how every per-
438
+ // service use is gated.
439
+ if (state.egress === true) {
440
+ const declarations = declarationsByKind.get("host.egress") ?? [];
441
+ const matched = declarations.some(([path]) => {
442
+ const grant = grants[path];
443
+ return grant != null && isHostGrant(grant) && grant.kind === "host.egress";
444
+ });
445
+ if (!matched) {
446
+ violations.push({
447
+ kind: "grant",
448
+ permissionKind: "host.egress",
449
+ use: true,
450
+ description: PERMISSION_META["host.egress"]?.phrase ?? "host.egress",
451
+ });
452
+ }
453
+ }
454
+ return violations;
455
+ }
456
+
457
+ /**
458
+ * System apps have no routes: no `router` on any service and no
459
+ * `verifyAnonymousRequest` (anonymous gates admit callers to an app's
460
+ * routes). Genuine network exposure goes through `host.port`.
461
+ * `getTrustedHeaders` is banned alongside them for a different
462
+ * reason - see its violation below.
463
+ *
464
+ * The `createSystemApp` types make all three unrepresentable at authoring
465
+ * time; this is the authoritative check over the *resolved* state, which
466
+ * catches what the types can't — a `Service` class instance smuggling a
467
+ * router (flattened at resolve time, bypassing `SystemServiceDefinition`)
468
+ * and raw `trustedHeaders` / `hasAnonymousGate` keys returned straight
469
+ * from `getDesiredState` (both are declared keys of `homeAppSchema`, so a
470
+ * packed/JS bundle can set them without the factory). Runs in
471
+ * `createSystemApp`'s evaluate (throws) and again in Home's graph
472
+ * verification (drops the app). Emits `app-kind` violations so
473
+ * `logViolations` needs no new branch.
474
+ */
475
+ export function checkSystemAppRoutes(state: HomeApp): AppKindViolation[] {
476
+ const violations: AppKindViolation[] = [];
477
+ for (const [serviceName, svc] of Object.entries(state.services)) {
478
+ if (svc.router != null) {
479
+ violations.push({
480
+ kind: "app-kind",
481
+ appKind: "system",
482
+ description: `service "${serviceName}" declares a router — routes are not allowed on a system app`,
483
+ });
484
+ }
485
+ }
486
+ if (state.trustedHeaders != null) {
487
+ violations.push({
488
+ kind: "app-kind",
489
+ appKind: "system",
490
+ // Not a routing argument: brokered identity also reaches a container
491
+ // through a page or widget loader, and a system app has those. It is a
492
+ // consent argument - a system app is seeded by the platform, with no
493
+ // operator install click to grant the disclosure.
494
+ description:
495
+ "getTrustedHeaders is not supported on a system app — a system app is " +
496
+ "seeded by the platform, so no operator ever consented to it learning " +
497
+ "who is signed in",
498
+ });
499
+ }
500
+ if (state.hasAnonymousGate === true) {
501
+ violations.push({
502
+ kind: "app-kind",
503
+ appKind: "system",
504
+ description:
505
+ "verifyAnonymousRequest is not supported on a system app — anonymous " +
506
+ "gates admit callers to an app's routes, and a system app has none",
507
+ });
508
+ }
509
+ return violations;
510
+ }
511
+
512
+ /**
513
+ * Reject permissions the app's kind may not declare.
514
+ *
515
+ * Each permission carries an `appKinds` scope at its declaration site
516
+ * (`PermissionMeta`), so the rule travels with the kind rather than living
517
+ * in a table here. Two directions: a single app reaching for a fleet-wide
518
+ * aggregation (`peers.routedApps` and friends), and a system app reaching
519
+ * for something that only means one box (`host.silo`) or that would
520
+ * multiply across the fleet (`home.apiAccess`).
521
+ *
522
+ * Reads the declared `permissionsFlat`, not the resolved state, because
523
+ * the restriction is on what the source *asks for*. A permission behind an
524
+ * optional section still counts: the operator can turn it on later, and
525
+ * whether the app may ask at all shouldn't depend on today's config. That
526
+ * is the opposite call from `partitionHostKinds`, which is about what the
527
+ * app needs to run right now.
528
+ *
529
+ * Runs at author time in the factories (throws) and again in Home's graph
530
+ * verification (drops the app), the same pair as `checkSystemAppRoutes`.
531
+ */
532
+ export function checkPermissionAppKinds(
533
+ appKind: AppKind,
534
+ permissionsFlat: Record<string, SerializablePermission>,
535
+ ): AppKindViolation[] {
536
+ const violations: AppKindViolation[] = [];
537
+ for (const [path, perm] of Object.entries(permissionsFlat)) {
538
+ if (permissionAllowsAppKind(perm.kind, appKind)) continue;
539
+ const phrase = PERMISSION_META[perm.kind as PermissionKind]?.phrase;
540
+ const allowed = appKind === "single" ? "system" : "single";
541
+ const factory = allowed === "system" ? "createSystemApp" : "createApp";
542
+ violations.push({
543
+ kind: "app-kind",
544
+ appKind,
545
+ description:
546
+ `"${path}" declares ${perm.kind}${phrase ? ` (${phrase})` : ""}, which only a ` +
547
+ `${allowed} app may declare — move the app to ${factory}, or drop the permission`,
548
+ });
549
+ }
550
+ return violations;
551
+ }
552
+
553
+ /**
554
+ * Is this hostname the Realm domain, or inside it?
555
+ *
556
+ * The one implementation of the reservation test, shared by the routing check
557
+ * below and by Home's `domain_claim` writer - which sees hostnames the routing
558
+ * check never does, because it reads the grant rather than the resolved state.
559
+ *
560
+ * `host` must already be normalized (the public-domain schema lowercases on the
561
+ * way in). `baseDomain` comes straight off `REALM_BASE_DOMAIN`, which nothing
562
+ * normalizes, so it is lowercased here: a mixed-case env value would otherwise
563
+ * turn the whole check into a no-op.
564
+ */
565
+ export function isUnderBaseDomain(host: string, baseDomain: string): boolean {
566
+ const base = baseDomain.toLowerCase();
567
+ return host === base || host.endsWith(`.${base}`);
568
+ }
569
+
570
+ /**
571
+ * Reject any `router.publicDomain` that falls under the Realm `baseDomain`.
572
+ * The structured authenticated subdomain `{appId}-{service}.{workerId}.
573
+ * {baseDomain}` already covers that space, gated by Home ForwardAuth, and the
574
+ * `.{baseDomain}`-scoped session cookie reaches every host under it - so an
575
+ * ungated public route there would leak a live Home session to the app. A
576
+ * public domain must therefore live outside the Realm domain. No-op when the
577
+ * install is unconfigured (`baseDomain` null).
578
+ */
579
+ export function checkPublicDomains(state: HomeApp, baseDomain: string | null): RoutingViolation[] {
580
+ if (!baseDomain) return [];
581
+ const violations: RoutingViolation[] = [];
582
+ for (const [serviceName, svc] of Object.entries(state.services)) {
583
+ for (const host of svc.router?.publicDomain?.hosts ?? []) {
584
+ if (isUnderBaseDomain(host, baseDomain)) {
585
+ violations.push({
586
+ kind: "routing",
587
+ serviceName,
588
+ description: `router.publicDomain "${host}" is under the Realm domain "${baseDomain}", which is reserved for authenticated subdomains. Use a domain outside "${baseDomain}" for public access — the authenticated subdomain is provisioned automatically.`,
589
+ });
590
+ }
591
+ }
592
+ }
593
+ return violations;
594
+ }
@@ -0,0 +1,21 @@
1
+ import { apply, type Resolved } from "./apply.ts";
2
+ import { containsRef, type Deferred } from "./ref.ts";
3
+
4
+ /**
5
+ * Serialize a tree of primitives + `Ref<string>` to a string, the shared
6
+ * engine behind `toJson` / `toYaml` / `toIni` / `toXml`.
7
+ *
8
+ * No refs → serialize eagerly and return a plain `string`. Refs present →
9
+ * defer via `apply()`: the Home resolves every ref, then re-runs
10
+ * `serialize` with the resolved (ref-free) tree and splices the result.
11
+ * Resolving *before* serializing is what lets format-specific escaping
12
+ * (JSON/YAML quoting) see the real value — a concat-spliced ref can't be
13
+ * escaped because its value isn't known until deploy time.
14
+ */
15
+ export function serializeRefs<T>(
16
+ value: T,
17
+ serialize: (resolved: Resolved<T>) => string,
18
+ ): string | Deferred<string> {
19
+ if (!containsRef(value)) return serialize(value as Resolved<T>);
20
+ return apply(value, serialize);
21
+ }