@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,462 @@
1
+ // The RPC catalogue: one schema pair per method in `todo/ideas/uplink.md`
2
+ // § 4 RPC, plus the typed helpers that call and answer them, so neither end
3
+ // ever hand-encodes JSON.
4
+ //
5
+ // Shapes only. Who may call what, and what a caller does while the session is
6
+ // down, are Home's and the worker's decisions — this file has no opinion.
7
+
8
+ import { z } from "zod";
9
+ import { HEADER_NAME_RE, HEADER_VALUE_RE } from "../../http-header.ts";
10
+ import { WORKER_CAPABILITIES, type WorkerCapability } from "../../permissions.ts";
11
+ import {
12
+ homeProxyRequestSchema,
13
+ homeProxyResponseSchema,
14
+ } from "../../runtime/docker/home-proxy-server/protocol.ts";
15
+ import { nodeVersionSchema } from "../node-version.ts";
16
+ import type { SiloOrphan } from "../silo-paths.ts";
17
+ import { UPDATE_ERROR_MAX_CHARS } from "../update-files.ts";
18
+ import { UplinkError, UplinkProtocolError } from "./errors.ts";
19
+ import { decodeUtf8, encodeUtf8 } from "./frame.ts";
20
+ import { jsonResponse, type Rpc, type RpcHandler } from "./rpc.ts";
21
+
22
+ /** The response header `state.pull` answers a 200 with. */
23
+ export const ETAG_HEADER = "etag";
24
+
25
+ // --- shared pieces ------------------------------------------------------
26
+
27
+ const capabilitiesSchema = z.array(
28
+ z.enum(WORKER_CAPABILITIES as readonly [WorkerCapability, ...WorkerCapability[]]),
29
+ );
30
+
31
+ // The worker's own view of its public address, from the acme-dns echo. Home
32
+ // cross-checks it against the socket's `X-Forwarded-For` but stores this one
33
+ // (`todo/ideas/uplink.md` § Identity). A junk filter, not a trust boundary.
34
+ const publicIpSchema = z.union([z.ipv4(), z.ipv6()]);
35
+
36
+ /** What both `enroll` and `hello` hand back once the worker row is known. */
37
+ const workerIdentitySchema = z.object({
38
+ workerId: z.string().min(1),
39
+ slug: z.string().min(1),
40
+ // Null when the realm has no Realm DNS at all, which is what keeps a
41
+ // self-hosted install from quietly calling yourrealm.eu.
42
+ publicIpUrl: z.string().nullable(),
43
+ });
44
+
45
+ const okSchema = z.object({ ok: z.literal(true) });
46
+
47
+ /**
48
+ * What the box's `realm-ingress` container is running, or null where there is
49
+ * no such container.
50
+ *
51
+ * Read off the container itself rather than off `etc/ingress/.env`, and the
52
+ * difference is the whole point: the `.env` says what this box is *configured*
53
+ * to run, while this says what it *is* running, and the two differ for exactly
54
+ * as long as an update is in flight or has failed. That gap is what the
55
+ * `applied` signal exists to see. It is still the pinned `name@sha256:` ref,
56
+ * because compose interpolates the `.env` pin into the service's `image:` and
57
+ * the container records that string verbatim.
58
+ *
59
+ * The ingress holds no session of its own - it is a proxy, not a node - so this
60
+ * is the only way its version reaches Home.
61
+ */
62
+ const ingressVersionSchema = z.object({ imageRef: z.string().min(1) }).nullable();
63
+
64
+ /**
65
+ * The platform compose projects a worker will write a `.update` for.
66
+ *
67
+ * Two, and neither is `home` or `updater`: Home writes its own box's
68
+ * `etc/home/.update` directly, and the updater cannot apply an update to
69
+ * itself. Restricting it here rather than in the worker is what makes a wrong
70
+ * project a 400 on the wire instead of a path traversal argument.
71
+ */
72
+ export const UPDATE_PROJECTS = ["worker", "ingress"] as const;
73
+ export type UpdateProject = (typeof UPDATE_PROJECTS)[number];
74
+
75
+ /** Longest `message` a per-app apply result carries: the tail of compose's stderr. */
76
+ export const APPLY_MESSAGE_MAX_CHARS = 2048;
77
+ /** Longest `error` an `update.status` carries: the file cap, declared beside its reader. */
78
+ export { UPDATE_ERROR_MAX_CHARS };
79
+
80
+ /**
81
+ * One app's result from a reconcile. `message` is the tail of compose's stderr
82
+ * or the spawn error - what the box said, so Home can show it rather than an
83
+ * "applied" that means only "the etag landed".
84
+ */
85
+ export const appApplyResultSchema = z.discriminatedUnion("ok", [
86
+ z.object({ appId: z.string().min(1), ok: z.literal(true) }),
87
+ z.object({
88
+ appId: z.string().min(1),
89
+ ok: z.literal(false),
90
+ message: z.string().min(1).max(APPLY_MESSAGE_MAX_CHARS),
91
+ }),
92
+ ]);
93
+ export type AppApplyResult = z.infer<typeof appApplyResultSchema>;
94
+
95
+ /**
96
+ * What one of the box's updater-driven compose projects is doing. `pending` is
97
+ * "`etc/<project>/.update` exists"; `error` is the trimmed content of
98
+ * `etc/<project>/.update.error`, or null when there is none. Independent of
99
+ * `version.imageRef`: a project can be pending with no error, failed with the
100
+ * token still present (the updater keeps `.update` as its retry token), or
101
+ * idle with a stale error file after a hand `rm`.
102
+ */
103
+ export const updateStatusSchema = z.object({
104
+ project: z.enum(UPDATE_PROJECTS),
105
+ pending: z.boolean(),
106
+ error: z.string().min(1).max(UPDATE_ERROR_MAX_CHARS).nullable(),
107
+ });
108
+ export type UpdateStatus = z.infer<typeof updateStatusSchema>;
109
+
110
+ // A `[name, value]` list rather than a record, in both directions: the
111
+ // ForwardAuth hop relays request headers wholesale and a response can carry
112
+ // more than one `set-cookie`, which a `Record<string, string>` would lose.
113
+ const headerPairsSchema = z.array(z.tuple([z.string(), z.string()]));
114
+
115
+ // Mirrors the worker's `/ui-proxy` body. The port bound against the app's own
116
+ // declared ports is the worker's check, not this schema's.
117
+ const uiProxyRequestSchema = z.object({
118
+ appId: z.string().min(1),
119
+ service: z.string().min(1),
120
+ port: z.number().int().positive().max(65535),
121
+ path: z.string().startsWith("/"),
122
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
123
+ query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
124
+ headers: z
125
+ .record(z.string(), z.string())
126
+ .refine(
127
+ (h) => Object.entries(h).every(([k, v]) => HEADER_NAME_RE.test(k) && HEADER_VALUE_RE.test(v)),
128
+ "invalid header",
129
+ )
130
+ .optional(),
131
+ body: z.unknown().optional(),
132
+ });
133
+
134
+ // Typed against the interface so the two cannot drift.
135
+ const siloOrphanSchema: z.ZodType<SiloOrphan> = z.object({
136
+ id: z.string().min(1),
137
+ mtime: z.string().nullable(),
138
+ sizeBytes: z.number().nullable(),
139
+ });
140
+
141
+ // --- the catalogue ------------------------------------------------------
142
+
143
+ /**
144
+ * Every method, by name. An entry with a `result` is JSON both ways and goes
145
+ * through `callJson`/`jsonHandler`; an entry with only `params` is
146
+ * byte-bodied and has its own helper below.
147
+ */
148
+ export const methods = {
149
+ /** Worker → Home, on an unenrolled session. The only method one accepts. */
150
+ enroll: {
151
+ params: z.object({
152
+ otp: z.string().min(1),
153
+ /** Display name; the installer defaults it to the box's hostname. */
154
+ name: z.string().trim().max(64).optional(),
155
+ capabilities: capabilitiesSchema,
156
+ version: nodeVersionSchema,
157
+ /** What this box's ingress is running, or null if it has none. */
158
+ ingress: ingressVersionSchema,
159
+ publicIp: publicIpSchema.optional(),
160
+ }),
161
+ result: workerIdentitySchema,
162
+ },
163
+
164
+ /**
165
+ * Worker → Home, first call on every connect: the door. 404 means the row is
166
+ * gone.
167
+ *
168
+ * This shape never changes. Every fact about the box goes in `announce`
169
+ * instead, precisely so that a worker and a Home on different builds can
170
+ * still open a session - which is the only way Home can tell that box to
171
+ * update. A `hello` Home could refuse strands the box until an operator
172
+ * writes the digest into `etc/worker/.update` by hand.
173
+ *
174
+ * The answer carries Home's own `protocol`. A worker that reads back a
175
+ * number other than its own holds the session and waits: it announces
176
+ * nothing, pulls nothing, and serves only what Home asks of it - `update`,
177
+ * which is what moves it onto Home's number.
178
+ */
179
+ hello: {
180
+ params: z.object({
181
+ /**
182
+ * The catalogue version this worker speaks. Absent means 0: a worker from
183
+ * before the door existed says nothing here, and 0 is never a version a
184
+ * door-aware worker announces.
185
+ *
186
+ * The default is part of the door's definition rather than a shim to
187
+ * remove later. The door is frozen, so the rule that reads a silent
188
+ * worker as 0 is frozen with it.
189
+ */
190
+ protocol: z.number().int().nonnegative().default(0),
191
+ version: nodeVersionSchema,
192
+ }),
193
+ result: workerIdentitySchema.extend({ protocol: z.number().int().nonnegative() }),
194
+ },
195
+
196
+ /**
197
+ * Worker → Home, once per connect, right after a `hello` whose answer carried
198
+ * a matching `protocol`. Everything the box knows about itself, replacing
199
+ * whatever Home held for this worker.
200
+ *
201
+ * Free to change in a way `hello` is not: a worker only ever sends it to a
202
+ * Home that agreed on the catalogue version first.
203
+ */
204
+ announce: {
205
+ params: z.object({
206
+ capabilities: capabilitiesSchema,
207
+ /** What this box's ingress is running, or null if it has none. */
208
+ ingress: ingressVersionSchema,
209
+ /** What this worker is actually running, not what it was last sent. */
210
+ appliedEtag: z.string().nullable(),
211
+ /**
212
+ * The box's whole update picture, replacing whatever Home held for this
213
+ * worker. A project absent here is unknown on this box (no
214
+ * `etc/<project>` dir); a worker with no `etc/` at all sends `[]`.
215
+ */
216
+ updates: z.array(updateStatusSchema),
217
+ publicIp: publicIpSchema.optional(),
218
+ }),
219
+ result: okSchema,
220
+ },
221
+
222
+ /** Worker → Home. 304 unchanged, 503 pending, 200 the tar.gz as bytes. */
223
+ "state.pull": {
224
+ params: z.object({
225
+ etag: z.string().nullable(),
226
+ publicIp: publicIpSchema.optional(),
227
+ }),
228
+ },
229
+
230
+ /**
231
+ * Worker → Home, after a reconcile lands. `apps` is one entry per app the
232
+ * worker ran `compose up` for, so the etag says what was pulled and the
233
+ * entries say what actually came up.
234
+ */
235
+ "state.applied": {
236
+ params: z.object({ etag: z.string().min(1), apps: z.array(appApplyResultSchema) }),
237
+ result: okSchema,
238
+ },
239
+
240
+ /** Worker → Home, one stream per gated request. The hot path. */
241
+ verify: {
242
+ params: z.object({
243
+ /** The original request's query string, `?` included, or empty. */
244
+ search: z.string(),
245
+ headers: headerPairsSchema,
246
+ }),
247
+ result: z.object({
248
+ status: z.number().int(),
249
+ headers: headerPairsSchema,
250
+ /** Base64: the relayed body is whatever the gate answered with. */
251
+ body: z.base64(),
252
+ }),
253
+ },
254
+
255
+ /** Worker → Home, the `home.apiAccess` broker, with the calling app id. */
256
+ apiAccess: {
257
+ params: homeProxyRequestSchema.extend({ appId: z.string().min(1) }),
258
+ result: homeProxyResponseSchema,
259
+ },
260
+
261
+ /**
262
+ * Worker → Home, when a project's `.update` / `.update.error` changes on
263
+ * disk. One row, upserted; `announce.updates` is the whole set.
264
+ */
265
+ "update.status": {
266
+ params: updateStatusSchema,
267
+ result: okSchema,
268
+ },
269
+
270
+ /** Home → worker. Zero payload; dropped while the session is down. */
271
+ reconcile: {
272
+ params: z.object({}),
273
+ result: okSchema,
274
+ },
275
+
276
+ /** Home → worker. The response status is the proxied one, body the bytes. */
277
+ uiProxy: {
278
+ params: uiProxyRequestSchema,
279
+ },
280
+
281
+ /**
282
+ * Home → worker. The worker writes `etc/<project>/.update`; its sidecar
283
+ * applies it. One method for both roles the worker can move, because the box
284
+ * has one sidecar and one etc/ tree - the project names which of them.
285
+ */
286
+ update: {
287
+ params: z.object({
288
+ imageRef: z.string().min(1),
289
+ project: z.enum(UPDATE_PROJECTS),
290
+ }),
291
+ result: okSchema,
292
+ },
293
+
294
+ /** Home → worker. `live` is every Silo id that still has a row. */
295
+ siloOrphans: {
296
+ params: z.object({ live: z.array(z.string()) }),
297
+ result: z.array(siloOrphanSchema),
298
+ },
299
+ } as const;
300
+
301
+ type Methods = typeof methods;
302
+
303
+ export type MethodName = keyof Methods;
304
+
305
+ /** The methods that are JSON in both directions. */
306
+ export type JsonMethod = {
307
+ [M in MethodName]: Methods[M] extends { result: z.ZodType } ? M : never;
308
+ }[MethodName];
309
+
310
+ export type Params<M extends MethodName> = z.infer<Methods[M]["params"]>;
311
+ export type Result<M extends JsonMethod> = z.infer<Methods[M]["result"]>;
312
+
313
+ /**
314
+ * A method answered — or is being answered — with a non-2xx status.
315
+ *
316
+ * Both directions on purpose: `callJson` throws it with what the peer said,
317
+ * and a handler throws it to say what to answer (`hello` → 404 for a worker
318
+ * whose row is gone). `status` is the contract; `body` is diagnostic.
319
+ */
320
+ export class MethodError extends UplinkError {
321
+ readonly method: string;
322
+ readonly status: number;
323
+ readonly body: string;
324
+
325
+ constructor(method: string, status: number, body: string) {
326
+ super(`${method} answered ${status}${body === "" ? "" : `: ${body.slice(0, 200)}`}`);
327
+ this.name = "MethodError";
328
+ this.method = method;
329
+ this.status = status;
330
+ this.body = body;
331
+ }
332
+ }
333
+
334
+ // --- calling ------------------------------------------------------------
335
+
336
+ /** Calls a JSON method and validates the answer against its result schema. */
337
+ export async function callJson<M extends JsonMethod>(
338
+ rpc: Rpc,
339
+ method: M,
340
+ params: Params<M>,
341
+ ): Promise<Result<M>> {
342
+ const spec = methods[method] as { params: z.ZodType; result: z.ZodType };
343
+ const res = await rpc.call(method, {}, encodeJson(spec.params, params));
344
+ const text = decodeUtf8(await readAll(res.body));
345
+ if (res.status < 200 || res.status >= 300) throw new MethodError(method, res.status, text);
346
+ return parseResult(method, spec.result, text) as Result<M>;
347
+ }
348
+
349
+ export interface StatePull {
350
+ /** 200 with a body, 304 unchanged, 503 while the first recompute pends. */
351
+ status: number;
352
+ /** Present on a 200; what the next pull sends back as `etag`. */
353
+ etag?: string;
354
+ /** The tar.gz, unread. The caller drains or cancels it. */
355
+ body: ReadableStream<Uint8Array>;
356
+ }
357
+
358
+ /** `state.pull`, whose 200 body is the per-worker bundle rather than JSON. */
359
+ export async function pullState(rpc: Rpc, params: Params<"state.pull">): Promise<StatePull> {
360
+ const res = await rpc.call("state.pull", {}, encodeJson(methods["state.pull"].params, params));
361
+ const etag = res.headers[ETAG_HEADER];
362
+ return etag === undefined
363
+ ? { status: res.status, body: res.body }
364
+ : { status: res.status, etag, body: res.body };
365
+ }
366
+
367
+ export interface UiProxyResult {
368
+ /** The status the container answered the worker with. */
369
+ status: number;
370
+ headers: Record<string, string>;
371
+ body: Uint8Array;
372
+ }
373
+
374
+ /** `uiProxy`, whose response body is bytes the worker relayed verbatim. */
375
+ export async function callUiProxy(rpc: Rpc, payload: Params<"uiProxy">): Promise<UiProxyResult> {
376
+ const res = await rpc.call("uiProxy", {}, encodeJson(methods.uiProxy.params, payload));
377
+ return { status: res.status, headers: res.headers, body: await readAll(res.body) };
378
+ }
379
+
380
+ // --- answering ----------------------------------------------------------
381
+
382
+ /**
383
+ * Wraps a handler for one JSON method: the body is parsed against its params
384
+ * schema (400 on a mismatch) and the return value is answered as JSON. Throw
385
+ * `MethodError` to answer any other status.
386
+ */
387
+ export function jsonHandler<M extends JsonMethod>(
388
+ method: M,
389
+ fn: (params: Params<M>) => Promise<Result<M>> | Result<M>,
390
+ ): RpcHandler {
391
+ const spec = methods[method] as { params: z.ZodType };
392
+ return async (req) => {
393
+ let raw: unknown;
394
+ try {
395
+ raw = JSON.parse(decodeUtf8(await readAll(req.body)));
396
+ } catch {
397
+ return jsonResponse(400, { error: `${method}: body is not JSON` });
398
+ }
399
+ const parsed = spec.params.safeParse(raw);
400
+ if (!parsed.success) {
401
+ return jsonResponse(400, { error: `${method}: ${issueText(parsed.error)}` });
402
+ }
403
+ try {
404
+ return jsonResponse(200, await fn(parsed.data as Params<M>));
405
+ } catch (e) {
406
+ // Anything else is a bug, and rpc.ts frames it as a 500.
407
+ if (e instanceof MethodError) return jsonResponse(e.status, { error: e.body });
408
+ throw e;
409
+ }
410
+ };
411
+ }
412
+
413
+ // --- helpers ------------------------------------------------------------
414
+
415
+ function encodeJson(schema: z.ZodType, params: unknown): Uint8Array {
416
+ // Parsed on the way out too: the refinements a type cannot express — a
417
+ // `path` that is safe, a header value that is printable — are exactly the
418
+ // ones worth catching before they reach the peer.
419
+ return encodeUtf8(JSON.stringify(schema.parse(params)));
420
+ }
421
+
422
+ function parseResult(method: string, schema: z.ZodType, text: string): unknown {
423
+ let value: unknown;
424
+ try {
425
+ value = JSON.parse(text);
426
+ } catch {
427
+ throw new UplinkProtocolError(`${method} answered with a body that is not JSON`);
428
+ }
429
+ const parsed = schema.safeParse(value);
430
+ if (!parsed.success) {
431
+ throw new UplinkProtocolError(
432
+ `${method} answered an unexpected shape: ${issueText(parsed.error)}`,
433
+ );
434
+ }
435
+ return parsed.data;
436
+ }
437
+
438
+ function issueText(error: z.ZodError): string {
439
+ const issue = error.issues[0];
440
+ if (issue === undefined) return "invalid";
441
+ const path = issue.path.join(".");
442
+ return path === "" ? issue.message : `${path}: ${issue.message}`;
443
+ }
444
+
445
+ async function readAll(body: ReadableStream<Uint8Array>): Promise<Uint8Array> {
446
+ const reader = body.getReader();
447
+ const chunks: Uint8Array[] = [];
448
+ let size = 0;
449
+ for (;;) {
450
+ const { value, done } = await reader.read();
451
+ if (done) break;
452
+ chunks.push(value);
453
+ size += value.length;
454
+ }
455
+ const out = new Uint8Array(size);
456
+ let offset = 0;
457
+ for (const chunk of chunks) {
458
+ out.set(chunk, offset);
459
+ offset += chunk.length;
460
+ }
461
+ return out;
462
+ }