@yourrealm/sdk 0.0.0-alpha.1414.ca4ff83e → 0.0.0-alpha.1438.ee6bc935

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.
@@ -0,0 +1,641 @@
1
+ import {
2
+ SPECULATIVE_SILO,
3
+ hostKey,
4
+ nameSchema
5
+ } from "./chunk-VHTUSZUX.js";
6
+
7
+ // src/worker-protocol/worker-state.ts
8
+ import { z as z3 } from "zod";
9
+
10
+ // src/worker-protocol/backup.ts
11
+ import { z } from "zod";
12
+ var keepSchema = z.object({
13
+ daily: z.number().int().nonnegative(),
14
+ weekly: z.number().int().nonnegative(),
15
+ monthly: z.number().int().nonnegative()
16
+ });
17
+ var hostPathSchema = z.string().regex(/^\.\/apps\/[^/]+\/./, { message: "must be ./apps/<appId>/..." }).refine(
18
+ (p) => !p.split("/").slice(1).some((seg) => seg === "" || seg === "." || seg === ".."),
19
+ {
20
+ message: "must be a normalized path with no empty, . or .. segments"
21
+ }
22
+ );
23
+ var backupTargetStrategySchema = z.discriminatedUnion("kind", [
24
+ z.object({ kind: z.literal("file-copy"), hostPath: hostPathSchema }),
25
+ z.object({ kind: z.literal("sqlite"), hostPath: hostPathSchema, file: z.string().min(1) }),
26
+ // `url` carries the resolved credentials; the Worker passes them to the dump
27
+ // tool as exec env. `version` is informational: the dump runs with the tool
28
+ // the database image ships, which always matches its server.
29
+ z.object({
30
+ kind: z.literal("pg-dump"),
31
+ url: z.string().min(1),
32
+ version: z.number().int().positive()
33
+ }),
34
+ z.object({
35
+ kind: z.literal("mysql-dump"),
36
+ url: z.string().min(1),
37
+ version: z.number().int().positive()
38
+ }),
39
+ z.object({ kind: z.literal("valkey-save"), url: z.string().min(1) })
40
+ ]);
41
+ var backupVolumeSchema = z.string().min(1).refine((v) => !v.includes(","), { message: "must not contain a comma" }).refine((v) => !v.split("/").some((seg) => seg === "" || seg === "." || seg === ".."), {
42
+ message: "must be a normalized relative path with no empty, . or .. segments"
43
+ });
44
+ var backupTargetSchema = z.object({
45
+ appId: z.string().min(1),
46
+ serviceKey: z.string().min(1),
47
+ volume: backupVolumeSchema,
48
+ strategy: backupTargetStrategySchema
49
+ }).refine(
50
+ (t) => !("hostPath" in t.strategy) || t.strategy.hostPath.startsWith(`./apps/${t.appId}/`),
51
+ {
52
+ message: "a target's folder must be its own app's",
53
+ path: ["strategy", "hostPath"]
54
+ }
55
+ );
56
+ var offsiteSchema = z.object({ enabled: z.literal(true), generation: z.string().min(1) });
57
+ var backupStateSchema = z.object({
58
+ /** Cron expression. */
59
+ schedule: z.string().min(1),
60
+ keep: keepSchema,
61
+ /** Derived per worker by Home; a worker only ever sees its own. */
62
+ resticPassword: z.string().min(1),
63
+ offsite: offsiteSchema.nullable(),
64
+ /**
65
+ * Disaster recovery: scheduled and startup runs and offsite copies don't
66
+ * start, so a rebuilt box writes nothing to the bucket before the operator
67
+ * has restored from it. Restores and the snapshot listing still run.
68
+ */
69
+ hold: z.boolean(),
70
+ targets: z.array(backupTargetSchema).min(1).refine((ts) => new Set(ts.map((t) => backupTargetKey(t))).size === ts.length, {
71
+ message: "two targets name the same app, service and volume"
72
+ })
73
+ }).nullable();
74
+ function backupTargetKey(t) {
75
+ return `${t.appId}/${t.serviceKey}/${t.volume}`;
76
+ }
77
+ var BACKUP_SAFETY_TAG = "safety";
78
+ var STRATEGY_TAG_PREFIX = "strategy:";
79
+ function backupSnapshotTags(t, kind, safety = false) {
80
+ return [
81
+ backupTargetKey(t),
82
+ `${STRATEGY_TAG_PREFIX}${kind}`,
83
+ ...safety ? [BACKUP_SAFETY_TAG] : []
84
+ ];
85
+ }
86
+ function parseBackupSnapshotTags(tags) {
87
+ let target = null;
88
+ let kind = null;
89
+ let safety = false;
90
+ for (const tag of tags) {
91
+ if (tag === BACKUP_SAFETY_TAG) {
92
+ safety = true;
93
+ } else if (tag.startsWith(STRATEGY_TAG_PREFIX)) {
94
+ kind = tag.slice(STRATEGY_TAG_PREFIX.length) || null;
95
+ } else {
96
+ const m = /^([^/]+)\/([^/]+)\/(.+)$/.exec(tag);
97
+ if (m !== null) target = { appId: m[1], serviceKey: m[2], volume: m[3] };
98
+ }
99
+ }
100
+ return target === null || kind === null ? null : { ...target, kind, safety };
101
+ }
102
+ var BACKUP_FILE_TYPES = ["config", "data", "index", "keys", "locks", "snapshots"];
103
+ var backupSignRequestSchema = z.object({
104
+ method: z.enum(["GET", "HEAD", "PUT", "DELETE", "LIST"]),
105
+ type: z.enum(BACKUP_FILE_TYPES),
106
+ /** The file's name. Absent for `config` and `LIST`. */
107
+ name: z.string().min(1).optional(),
108
+ /** Lowercase hex SHA-256 of a `PUT` body, which the signature covers. */
109
+ sha256: z.string().min(1).optional(),
110
+ /** Byte length of a `PUT` body. */
111
+ length: z.number().int().nonnegative().optional(),
112
+ /** A `LIST` page's `NextContinuationToken`. */
113
+ continuation: z.string().min(1).optional(),
114
+ /**
115
+ * `offsite.generation` from the state the run started with. Home refuses
116
+ * (409) one that isn't the bucket's current generation.
117
+ */
118
+ generation: z.string().min(1)
119
+ });
120
+ var BACKUP_STALE_GENERATION = "the bucket changed since this run started";
121
+ var BACKUP_FILE_EXISTS = "the file is already in the bucket";
122
+ var backupSignResultSchema = z.object({
123
+ url: z.string().min(1),
124
+ headers: z.record(z.string(), z.string())
125
+ });
126
+ var outcomeSchema = z.enum(["ok", "failed", "skipped"]);
127
+ var targetStatusSchema = z.object({
128
+ appId: z.string(),
129
+ serviceKey: z.string(),
130
+ volume: z.string(),
131
+ kind: z.string(),
132
+ /** ISO-8601 of the attempt that settled this outcome. */
133
+ at: z.string(),
134
+ outcome: outcomeSchema,
135
+ /** Why it failed or was skipped. Absent on `ok`. */
136
+ error: z.string().optional(),
137
+ /** Attempts in that run; above 1 means the startup retry ran. */
138
+ attempts: z.number()
139
+ });
140
+ var repoStatusBase = {
141
+ at: z.string(),
142
+ outcome: outcomeSchema,
143
+ error: z.string().optional()
144
+ };
145
+ var repoStatusSchema = z.discriminatedUnion("repo", [
146
+ z.object({ repo: z.literal("local"), ...repoStatusBase }),
147
+ z.object({
148
+ repo: z.literal("offsite"),
149
+ ...repoStatusBase,
150
+ /** `offsite.generation` the copy ran against; null if it never got one. */
151
+ generation: z.string().nullable()
152
+ })
153
+ ]);
154
+ var SNAPSHOT_ID_RE = /^[0-9a-f]{64}$/;
155
+ var snapshotSchema = z.object({
156
+ /**
157
+ * restic's full id, not `short_id`. A local snapshot and its offsite copy
158
+ * have different ids; a restore names one of these exact strings.
159
+ */
160
+ id: z.string().regex(SNAPSHOT_ID_RE),
161
+ time: z.string(),
162
+ /** Taken by a restore just before it touched anything. */
163
+ safety: z.boolean()
164
+ });
165
+ var snapshotGroupSchema = z.object({
166
+ appId: z.string(),
167
+ serviceKey: z.string(),
168
+ volume: z.string(),
169
+ /** The strategy, from the `strategy:` tag. */
170
+ kind: z.string(),
171
+ local: z.array(snapshotSchema),
172
+ /** Null when offsite isn't set up, or its listing failed (see `repos`). */
173
+ offsite: z.array(snapshotSchema).nullable()
174
+ });
175
+ var restoreSourceSchema = z.enum(["local", "offsite"]);
176
+ var restoreItemSchema = z.object({
177
+ serviceKey: z.string().min(1),
178
+ volume: backupVolumeSchema,
179
+ snapshotId: z.string().regex(SNAPSHOT_ID_RE),
180
+ source: restoreSourceSchema
181
+ });
182
+ var backupRestoreRequestSchema = z.object({
183
+ restoreId: z.uuid(),
184
+ appId: z.string().min(1),
185
+ items: z.array(restoreItemSchema).min(1).refine(
186
+ (items) => new Set(items.map((i) => `${i.serviceKey}/${i.volume}`)).size === items.length,
187
+ { message: "a volume is named twice" }
188
+ )
189
+ });
190
+ var BACKUP_RESTORE_PHASES = [
191
+ "queued",
192
+ "safety",
193
+ "stopping",
194
+ "restoring",
195
+ "starting",
196
+ "done",
197
+ "failed"
198
+ ];
199
+ var BACKUP_RESTORE_INTERRUPTED = "the Worker restarted during the restore";
200
+ var restoreSchema = z.object({
201
+ restoreId: z.string(),
202
+ appId: z.string(),
203
+ startedAt: z.string(),
204
+ finishedAt: z.string().nullable(),
205
+ phase: z.enum(BACKUP_RESTORE_PHASES),
206
+ items: z.array(
207
+ restoreItemSchema.extend({
208
+ outcome: z.enum(["pending", "ok", "failed"]),
209
+ error: z.string().optional()
210
+ })
211
+ ),
212
+ /** It failed, and the safety snapshot was put back. */
213
+ rolledBack: z.boolean(),
214
+ /** What happened, when it failed. */
215
+ error: z.string().nullable()
216
+ });
217
+ var backupStatusSchema = z.object({
218
+ /** ISO-8601 by the Worker's clock when it sent this, for Home to measure drift. */
219
+ sentAt: z.iso.datetime(),
220
+ offsiteConfigured: z.boolean(),
221
+ schedule: z.string(),
222
+ retention: keepSchema,
223
+ /** A run is in progress right now. */
224
+ running: z.boolean(),
225
+ /** ISO-8601, null before the first run settles. */
226
+ lastRunAt: z.string().nullable(),
227
+ nextRunAt: z.string().nullable(),
228
+ /** The schedule doesn't parse, so only the startup run happens. */
229
+ scheduleError: z.string().nullable(),
230
+ /** The last run stopped before reaching any target (an unreadable config, say). */
231
+ runError: z.string().nullable(),
232
+ targets: z.array(targetStatusSchema),
233
+ repos: z.array(repoStatusSchema),
234
+ snapshots: z.array(snapshotGroupSchema),
235
+ /**
236
+ * ISO-8601 by the Worker's clock of its last successful listing of the
237
+ * bucket, by a run or a read-only refresh. Null with no bucket, or before
238
+ * the first listing that worked: `snapshots[].offsite` says nothing until
239
+ * this is set.
240
+ */
241
+ offsiteListedAt: z.string().nullable(),
242
+ /**
243
+ * Apps a restore left stopped on data Realm can't vouch for - a rollback
244
+ * failed, or a dropped database could not be put back. Nothing starts one
245
+ * until an admin releases it (`backup.release`) or a later restore of it
246
+ * finishes. An app is otherwise held only while its restore runs, which
247
+ * `restores` already shows.
248
+ */
249
+ held: z.array(z.object({ appId: z.string(), restoreId: z.string(), reason: z.string() })),
250
+ /** The latest restore of each app that has had one since the Worker started. */
251
+ restores: z.array(restoreSchema)
252
+ });
253
+
254
+ // src/worker-protocol/ingress.ts
255
+ import { z as z2 } from "zod";
256
+ var acmeIssuerSchema = z2.object({
257
+ serverUrl: z2.string(),
258
+ username: z2.string(),
259
+ apiKey: z2.string(),
260
+ ca: z2.string(),
261
+ caRootPem: z2.string()
262
+ });
263
+ var ingressRouterSchema = z2.object({
264
+ containerPort: z2.number().int().min(1).max(65535),
265
+ /** Operator-granted hostnames for the additional ungated public route. */
266
+ publicDomain: z2.union([z2.string(), z2.array(z2.string())]).optional(),
267
+ /** With several granted hosts: serve them all, or serve one and 301 the rest. */
268
+ domainMode: z2.enum(["redirect", "all"]),
269
+ pathPrefix: z2.string().regex(/^\/[a-zA-Z0-9/_-]*$/, "pathPrefix must start with / and contain only [a-zA-Z0-9/_-]").optional(),
270
+ compress: z2.boolean(),
271
+ maxRequestSizeMb: z2.number().nullable(),
272
+ rateLimit: z2.union([
273
+ z2.number(),
274
+ z2.object({
275
+ requests: z2.number(),
276
+ window: z2.string().regex(/^\d+(ms|s|m|h)$/, "window must be a duration like 10s, 1m, 1h")
277
+ })
278
+ ]).nullable(),
279
+ /** Response-header overrides; `null` deletes the renderer's default. */
280
+ headers: z2.record(z2.string(), z2.string().nullable()).optional()
281
+ });
282
+ var ingressRoutedAppSchema = z2.object({
283
+ appId: z2.string(),
284
+ services: z2.array(z2.object({ serviceId: z2.string(), router: ingressRouterSchema })),
285
+ /**
286
+ * Header-brokering names the app declared via `getTrustedHeaders`. The
287
+ * renderer injects these on the authenticated route (lifted off the
288
+ * forward-auth response) and strips them everywhere else, so identity can't
289
+ * be spoofed.
290
+ */
291
+ trustedHeaderKeys: z2.array(z2.string()).optional()
292
+ });
293
+ var ingressInputSchema = z2.object({
294
+ /** Every routed service on this worker, ordered canonically by `appId`. */
295
+ routedApps: z2.array(ingressRoutedAppSchema),
296
+ /**
297
+ * The Home dashboard's own host, `{homeSlug}.{workerId}.{baseDomain}` - one
298
+ * label under the worker wildcard, which a renderer has to match ahead of
299
+ * that wildcard so the not-routed holding page never shadows it.
300
+ *
301
+ * Null on a box that does not run Home. The name resolves realm-wide (it
302
+ * sits under the realm's wildcard), so emitting the route on a worker-only
303
+ * box would answer a hit there with a page claiming the control plane is
304
+ * down - and, under BYO public providers, cost a real per-host certificate
305
+ * on every worker. Home knows which box is which from `home_state
306
+ * .home_worker_id`.
307
+ */
308
+ dashboardHost: z2.string().nullable(),
309
+ /**
310
+ * This worker's short slug: the `{workerId}` level of every app subdomain,
311
+ * and the label it owns under acme-dns.
312
+ */
313
+ workerId: z2.string(),
314
+ /**
315
+ * The realm's domain base (`{slug}.yourrealm.me`, or the operator's own).
316
+ * With `workerId` it forms every structured authenticated subdomain. Empty
317
+ * on the placeholder identity, before an install is configured.
318
+ */
319
+ baseDomain: z2.string(),
320
+ /** DNS-01 inputs for the worker wildcard, or null (dev / e2e / first boot). */
321
+ acmeDns: acmeIssuerSchema.extend({ baseDomain: z2.string() }).nullable(),
322
+ /**
323
+ * BYO public-DNS providers, in issue order. `apiToken` is a plain string:
324
+ * a `Ref` cannot ride the wire, and by this point Home has resolved it.
325
+ */
326
+ publicProviders: z2.array(
327
+ z2.object({ name: z2.enum(["cloudflare", "hetzner"]), apiToken: z2.string() })
328
+ ),
329
+ /**
330
+ * CDNs in front of this realm, whose edge addresses ingress trusts when
331
+ * resolving the real client IP. The addresses themselves are not here - the
332
+ * renderer's runtime fetches and refreshes them.
333
+ */
334
+ cdnProviders: z2.array(z2.string()),
335
+ /**
336
+ * The signing-certificate fingerprints in the Digital Asset Links statement
337
+ * the ingress serves on every host - the statement's
338
+ * `sha256_cert_fingerprints`, which Chrome matches the companion APK's own
339
+ * signature against before it drops the Trusted Web Activity's toolbar.
340
+ *
341
+ * Operator-set rather than a constant, because which certificate signs the
342
+ * installed APK is a property of how that operator got it: Play App Signing
343
+ * re-signs with Google's key, direct distribution keeps the release
344
+ * keystore's, and a locally built debug APK carries a third.
345
+ *
346
+ * The shape is pinned here because the renderer inlines these strings into a
347
+ * JSON body Caddy's `ReplaceKnown` walks: uppercase hex bytes and colons can
348
+ * never form a `{placeholder}`, so a value that parses cannot reach into the
349
+ * replacer.
350
+ */
351
+ companionFingerprints: z2.array(
352
+ z2.string().regex(/^[0-9A-F]{2}(:[0-9A-F]{2}){31}$/, "expected 32 colon-separated uppercase hex bytes")
353
+ )
354
+ });
355
+ var PLATFORM_INGRESS_PORTS = [80, 443];
356
+
357
+ // src/worker-protocol/worker-state.ts
358
+ var dependsOnEdgeSchema = z3.object({
359
+ providerAppId: z3.string(),
360
+ providerServiceName: z3.string(),
361
+ condition: z3.enum(["started", "healthy"])
362
+ });
363
+ var duration = z3.number().int().nonnegative();
364
+ var containerPort = z3.number().int().min(1).max(65535);
365
+ var healthcheckSchema = z3.object({
366
+ test: z3.array(z3.string()),
367
+ interval: duration,
368
+ timeout: duration,
369
+ retries: z3.number().int().nonnegative(),
370
+ startPeriod: duration
371
+ });
372
+ var hostPortSchema = z3.object({
373
+ containerPort,
374
+ hostPort: z3.number().int().min(1).max(65535),
375
+ protocol: z3.enum(["tcp", "udp"]).optional()
376
+ });
377
+ var backupStrategySchema = z3.discriminatedUnion("kind", [
378
+ z3.object({ kind: z3.literal("file-copy") }),
379
+ z3.object({ kind: z3.literal("sqlite"), file: z3.string() }),
380
+ z3.object({ kind: z3.literal("pg-dump"), url: z3.string(), version: z3.number().int().positive() }),
381
+ z3.object({ kind: z3.literal("valkey-save"), url: z3.string() }),
382
+ z3.object({
383
+ kind: z3.literal("mysql-dump"),
384
+ url: z3.string(),
385
+ version: z3.number().int().positive()
386
+ })
387
+ ]);
388
+ var volumeSchema = z3.object({
389
+ // Refused by name: the wire is where "this was fully resolved" stops being a
390
+ // promise and becomes a check. A speculative eval fabricates a Silo so an app
391
+ // can be previewed before the operator picks one (`speculative-silo.ts`), and
392
+ // that placeholder is otherwise an ordinary absolute path - shipping it binds
393
+ // an empty directory in place of the operator's library, silently. `evalApps`
394
+ // refuses that combination up front; this is the half that cannot be bypassed.
395
+ hostPath: z3.string().refine((p) => p !== SPECULATIVE_SILO.hostPath, {
396
+ message: "the speculative Silo placeholder must never reach the wire"
397
+ }),
398
+ containerPath: z3.string(),
399
+ // The Silo group that owns this directory. The renderer turns it into
400
+ // `group_add` on every service that mounts the volume, so directory
401
+ // preparation and group membership are driven by the same field and can
402
+ // never disagree.
403
+ gid: z3.number().int().positive().optional(),
404
+ readOnly: z3.boolean().optional(),
405
+ backup: z3.union([backupStrategySchema, z3.literal(false)]).optional()
406
+ });
407
+ var wireServiceSchema = z3.object({
408
+ image: z3.string().min(1),
409
+ hostPorts: z3.array(hostPortSchema).optional(),
410
+ env: z3.record(z3.string(), z3.union([z3.string(), z3.number()])).optional(),
411
+ // containerPath -> 64-char hex digest the renderer resolves to bytes. The two
412
+ // sources hash different things: artifact refs collapse to their bare content
413
+ // sha, while inline content is keyed by *identity* — sha256 of
414
+ // `appId:serviceKey:fileKey` (plaintext rides in `WorkerApp.inlineFiles`). An
415
+ // inline file therefore keeps the same digest and worker path across content
416
+ // changes, so `--watch` consumers reload without recreating the container.
417
+ files: z3.record(z3.string().startsWith("/"), z3.string().regex(/^[a-f0-9]{64}$/)).optional(),
418
+ command: z3.array(z3.string()).optional(),
419
+ volumes: z3.array(volumeSchema).optional(),
420
+ gpu: z3.boolean().optional(),
421
+ iptables: z3.boolean().optional(),
422
+ joinNetworks: z3.array(z3.string()).optional(),
423
+ healthcheck: healthcheckSchema.optional(),
424
+ memory: z3.string().optional(),
425
+ cpu: z3.number().positive().max(128).optional(),
426
+ // Defaulted to 1 upstream, so always present on the wire.
427
+ replicas: z3.number().int().min(1).max(100),
428
+ labels: z3.array(z3.string()).optional(),
429
+ shmSizeMb: z3.number().int().positive().optional()
430
+ });
431
+ var workerAppSchema = z3.object({
432
+ appId: z3.string(),
433
+ // Flattened: in the resolve model these live under `.state` (matching
434
+ // `getDesiredState`'s shape), but on the wire only these two of that
435
+ // object's fields survive, so they're hoisted onto the app directly.
436
+ services: z3.record(nameSchema, wireServiceSchema),
437
+ /** `true` makes the per-app network `internal: true` (no egress). */
438
+ egress: z3.literal(true).optional(),
439
+ /** Worker-owned external nets this app's services join (e.g. `management-net`). */
440
+ externalNetworks: z3.array(z3.string()),
441
+ /**
442
+ * Service-name (unprefixed) -> producer edges. The renderer namespaces
443
+ * `providerServiceName` with `providerAppId` to form the final compose key.
444
+ */
445
+ dependsOn: z3.record(z3.string(), z3.array(dependsOnEdgeSchema)),
446
+ /** Plaintext for inline `files` entries, keyed by their identity digest. */
447
+ inlineFiles: z3.record(z3.string(), z3.string()),
448
+ /**
449
+ * Absolute host paths exempted from the absolute-mount gate and
450
+ * bound verbatim by the renderer (peer backup volumes, the logs socket).
451
+ */
452
+ allowedAbsoluteHostPaths: z3.array(z3.string()),
453
+ /**
454
+ * Host path the worker binds this app's `home-proxy.sock` at. Present only
455
+ * when the app holds a `home.apiAccess` grant.
456
+ */
457
+ homeProxySocketPath: z3.string().optional(),
458
+ /**
459
+ * Host path the worker binds this app's spawn broker socket at. Present
460
+ * only when the app holds a `host.spawn` grant.
461
+ */
462
+ spawnSocketPath: z3.string().optional(),
463
+ /**
464
+ * This app's registry credentials, keyed by registry host. The worker
465
+ * renders a per-app docker `config.json` from these so private images pull.
466
+ */
467
+ registryAuth: z3.record(hostKey, z3.object({ username: z3.string(), password: z3.string() })).optional()
468
+ });
469
+ var workerStateSchema = z3.object({
470
+ apps: z3.array(workerAppSchema),
471
+ /** SHAs of every artifact the worker keeps in `files/` after a reconcile. */
472
+ artifactHashes: z3.array(z3.string()),
473
+ /** Docker networks the worker container itself joins this reconcile. */
474
+ workerNetworks: z3.array(z3.string()),
475
+ /**
476
+ * Where traffic goes on this box, as `apps` says what runs on it. Runtime-
477
+ * neutral: the Docker worker renders it with `buildCaddyConfig`
478
+ * (`runtime/docker/ingress`) exactly as it renders `apps` with
479
+ * `convertToDockerCompose`.
480
+ *
481
+ * Required, not optional. Every producer can state an honest value - an
482
+ * unconfigured box gets the empty-identity placeholder, which is a real
483
+ * answer ("no domain yet") rather than a missing one - and a box with no
484
+ * schedulable app still needs a front door, so absence must never be a legal
485
+ * way to say "no ingress here".
486
+ */
487
+ ingress: ingressInputSchema,
488
+ /**
489
+ * What this box backs up, and where to. Null when nothing on it has a backup
490
+ * strategy - required-nullable like `ingress`, so "no backup here" is a
491
+ * stated answer. The Docker worker runs it in-process on its cron.
492
+ */
493
+ backup: backupStateSchema
494
+ });
495
+
496
+ // src/worker-protocol/node-version.ts
497
+ import { z as z4 } from "zod";
498
+ var nodeVersionSchema = z4.object({
499
+ // The pinned ref this container runs: `benjick/realm-worker@sha256:…` for a
500
+ // pulled image, a bare image ID for a locally-loaded one (see the installer's
501
+ // pin_image). This is the string an `applied` audit entry matches against.
502
+ imageRef: z4.string().nullable(),
503
+ // The commit the image was built from - a full SHA from the publish workflow,
504
+ // null for anything built outside it.
505
+ gitHash: z4.string().nullable(),
506
+ // ISO-8601 build timestamp, read back off the image's OCI `created` label by
507
+ // the publish workflow so the reported date and the image's own can't drift.
508
+ buildDate: z4.string().nullable()
509
+ });
510
+ function readEnv(name) {
511
+ const value = Deno.env.get(name);
512
+ return value === void 0 || value === "" ? null : value;
513
+ }
514
+ function readNodeVersion() {
515
+ return {
516
+ imageRef: readEnv("REALM_IMAGE_REF"),
517
+ gitHash: readEnv("REALM_GIT_HASH"),
518
+ buildDate: readEnv("REALM_BUILD_DATE")
519
+ };
520
+ }
521
+ function shortNodeVersion(version) {
522
+ if (version.gitHash) return version.gitHash.slice(0, 7);
523
+ const digest = version.imageRef?.split("sha256:")[1];
524
+ return digest ? digest.slice(0, 12) : null;
525
+ }
526
+
527
+ // src/worker-protocol/update-files.ts
528
+ var UPDATE_ERROR_MAX_CHARS = 2048;
529
+ var MAX_ERROR_FILE_BYTES = 16 * 1024;
530
+ async function readUpdateFiles(dir) {
531
+ const [pending, error] = await Promise.all([
532
+ isFile(`${dir}/.update`),
533
+ readError(`${dir}/.update.error`)
534
+ ]);
535
+ return { pending, error };
536
+ }
537
+ async function isFile(path) {
538
+ try {
539
+ return (await Deno.stat(path)).isFile;
540
+ } catch {
541
+ return false;
542
+ }
543
+ }
544
+ async function readError(path) {
545
+ let text;
546
+ try {
547
+ const stat = await Deno.stat(path);
548
+ if (!stat.isFile || stat.size > MAX_ERROR_FILE_BYTES) return null;
549
+ text = await Deno.readTextFile(path);
550
+ } catch {
551
+ return null;
552
+ }
553
+ const trimmed = text.trim();
554
+ if (trimmed === "") return null;
555
+ return trimmed.length > UPDATE_ERROR_MAX_CHARS ? trimmed.slice(-UPDATE_ERROR_MAX_CHARS) : trimmed;
556
+ }
557
+
558
+ // src/worker-protocol/app-dirs.ts
559
+ var REALM_APP_DIR_GID = 999999;
560
+ function isPreparedAppDir(appId, volume, exemptHostPaths) {
561
+ if (volume.gid !== void 0) return false;
562
+ if (exemptHostPaths.has(volume.hostPath)) return false;
563
+ if (!volume.hostPath.startsWith("./")) return false;
564
+ return scopeBindSource(volume.hostPath, appId) !== `./apps/${appId}`;
565
+ }
566
+ function scopeBindSource(source, appId) {
567
+ if (!source.startsWith("./")) return source;
568
+ const scoped = `./apps/${appId}/${source.slice(2)}`;
569
+ const segments = scoped.split("/");
570
+ const resolved = [];
571
+ for (const seg of segments) {
572
+ if (seg === "..") resolved.pop();
573
+ else if (seg !== "." && seg !== "") resolved.push(seg);
574
+ }
575
+ const normalized = "./" + resolved.join("/");
576
+ if (!normalized.startsWith(`./apps/${appId}/`) && normalized !== `./apps/${appId}`) {
577
+ throw new Error(`bind source escapes app directory: ${source}`);
578
+ }
579
+ return normalized;
580
+ }
581
+
582
+ // src/worker-protocol/silo-paths.ts
583
+ var REALM_SILO_DIR = "/opt/realm/worker/silo";
584
+ function managedSiloPath(id) {
585
+ return `${REALM_SILO_DIR}/${id}`;
586
+ }
587
+ function isManagedSiloPath(hostPath) {
588
+ return hostPath.startsWith(`${REALM_SILO_DIR}/`);
589
+ }
590
+ function shellQuote(path) {
591
+ return `'${path.replaceAll("'", "'\\''")}'`;
592
+ }
593
+ function siloSetupCommand(silo) {
594
+ const path = shellQuote(silo.hostPath);
595
+ return `chgrp -R ${silo.gid} ${path}
596
+ chmod g+rwxs ${path}`;
597
+ }
598
+ function siloWidenCommand(silo) {
599
+ return `find ${shellQuote(silo.hostPath)} -type d -exec chmod g+rwxs {} +`;
600
+ }
601
+ function siloReclaimCommand(orphan) {
602
+ return `ssh root@${orphan.workerIp}
603
+ rm -rf ${shellQuote(orphan.hostPath)}`;
604
+ }
605
+
606
+ export {
607
+ backupVolumeSchema,
608
+ backupStateSchema,
609
+ backupTargetKey,
610
+ BACKUP_SAFETY_TAG,
611
+ backupSnapshotTags,
612
+ parseBackupSnapshotTags,
613
+ BACKUP_FILE_TYPES,
614
+ backupSignRequestSchema,
615
+ BACKUP_STALE_GENERATION,
616
+ BACKUP_FILE_EXISTS,
617
+ backupSignResultSchema,
618
+ backupRestoreRequestSchema,
619
+ BACKUP_RESTORE_PHASES,
620
+ BACKUP_RESTORE_INTERRUPTED,
621
+ backupStatusSchema,
622
+ ingressInputSchema,
623
+ PLATFORM_INGRESS_PORTS,
624
+ dependsOnEdgeSchema,
625
+ wireServiceSchema,
626
+ workerAppSchema,
627
+ workerStateSchema,
628
+ nodeVersionSchema,
629
+ readNodeVersion,
630
+ shortNodeVersion,
631
+ readUpdateFiles,
632
+ REALM_APP_DIR_GID,
633
+ isPreparedAppDir,
634
+ scopeBindSource,
635
+ REALM_SILO_DIR,
636
+ managedSiloPath,
637
+ isManagedSiloPath,
638
+ siloSetupCommand,
639
+ siloWidenCommand,
640
+ siloReclaimCommand
641
+ };