@yourrealm/sdk 0.0.0-alpha.1451.8a8a7d44 → 0.0.0-alpha.1471.2b6cf8b8

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.
@@ -172,13 +172,51 @@ var snapshotGroupSchema = z.object({
172
172
  /** Null when offsite isn't set up, or its listing failed (see `repos`). */
173
173
  offsite: z.array(snapshotSchema).nullable()
174
174
  });
175
+ var BACKUP_PATH_MAX = 4096;
176
+ function isBackupRelPath(p) {
177
+ return p.length > 0 && p.length <= BACKUP_PATH_MAX && !p.includes("\0") && p.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
178
+ }
179
+ var relPathSchema = z.string().refine(isBackupRelPath, {
180
+ message: "must be a relative path with no empty, . or .. segments and no NUL"
181
+ });
182
+ var BACKUP_PATHS_MAX = 1e4;
183
+ var relPathsSchema = z.array(relPathSchema).min(1).max(BACKUP_PATHS_MAX).refine((ps) => new Set(ps).size === ps.length, { message: "a path is named twice" });
175
184
  var restoreSourceSchema = z.enum(["local", "offsite"]);
176
- var restoreItemSchema = z.object({
185
+ var snapshotRefSchema = z.object({
177
186
  serviceKey: z.string().min(1),
178
187
  volume: backupVolumeSchema,
179
188
  snapshotId: z.string().regex(SNAPSHOT_ID_RE),
180
189
  source: restoreSourceSchema
181
190
  });
191
+ var backupLsRequestSchema = snapshotRefSchema.extend({
192
+ appId: z.string().min(1),
193
+ path: z.union([z.literal(""), relPathSchema])
194
+ });
195
+ var lsEntrySchema = z.object({
196
+ name: z.string().min(1),
197
+ type: z.enum(["dir", "file", "symlink"]),
198
+ /** Bytes; files only. */
199
+ size: z.number().int().nonnegative().optional(),
200
+ /** ISO-8601, UTC. */
201
+ mtime: z.string()
202
+ });
203
+ var backupLsResultSchema = z.object({ entries: z.array(lsEntrySchema) });
204
+ var BACKUP_READ_ITEMS_MAX = 100;
205
+ var backupReadRequestSchema = z.object({
206
+ appId: z.string().min(1),
207
+ items: z.array(snapshotRefSchema.extend({ paths: relPathsSchema.optional() })).min(1).max(BACKUP_READ_ITEMS_MAX).refine(
208
+ (items) => new Set(items.map((i) => `${i.serviceKey}/${i.volume}`)).size === items.length,
209
+ { message: "a volume is named twice" }
210
+ )
211
+ });
212
+ var restoreItemSchema = snapshotRefSchema.extend({
213
+ /**
214
+ * Put back only these, relative to the volume's root: each becomes exactly
215
+ * the snapshot's version, and nothing else in the volume moves. Folder
216
+ * (file-copy) volumes only. Absent, the whole volume.
217
+ */
218
+ paths: relPathsSchema.optional()
219
+ });
182
220
  var backupRestoreRequestSchema = z.object({
183
221
  restoreId: z.uuid(),
184
222
  appId: z.string().min(1),
@@ -615,6 +653,12 @@ export {
615
653
  BACKUP_STALE_GENERATION,
616
654
  BACKUP_FILE_EXISTS,
617
655
  backupSignResultSchema,
656
+ isBackupRelPath,
657
+ BACKUP_PATHS_MAX,
658
+ backupLsRequestSchema,
659
+ backupLsResultSchema,
660
+ BACKUP_READ_ITEMS_MAX,
661
+ backupReadRequestSchema,
618
662
  backupRestoreRequestSchema,
619
663
  BACKUP_RESTORE_PHASES,
620
664
  BACKUP_RESTORE_INTERRUPTED,
package/dist/test.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  REALM_APP_DIR_GID,
3
3
  isPreparedAppDir,
4
4
  scopeBindSource
5
- } from "./chunk-NSZJJPMQ.js";
5
+ } from "./chunk-BSCX74FG.js";
6
6
  import {
7
7
  RealmCompileError,
8
8
  checkAppGrants,
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  BACKUP_FILE_EXISTS,
3
3
  BACKUP_FILE_TYPES,
4
+ BACKUP_PATHS_MAX,
5
+ BACKUP_READ_ITEMS_MAX,
4
6
  BACKUP_RESTORE_INTERRUPTED,
5
7
  BACKUP_RESTORE_PHASES,
6
8
  BACKUP_SAFETY_TAG,
@@ -8,6 +10,9 @@ import {
8
10
  PLATFORM_INGRESS_PORTS,
9
11
  REALM_APP_DIR_GID,
10
12
  REALM_SILO_DIR,
13
+ backupLsRequestSchema,
14
+ backupLsResultSchema,
15
+ backupReadRequestSchema,
11
16
  backupRestoreRequestSchema,
12
17
  backupSignRequestSchema,
13
18
  backupSignResultSchema,
@@ -18,6 +23,7 @@ import {
18
23
  backupVolumeSchema,
19
24
  dependsOnEdgeSchema,
20
25
  ingressInputSchema,
26
+ isBackupRelPath,
21
27
  isManagedSiloPath,
22
28
  isPreparedAppDir,
23
29
  managedSiloPath,
@@ -33,7 +39,7 @@ import {
33
39
  wireServiceSchema,
34
40
  workerAppSchema,
35
41
  workerStateSchema
36
- } from "./chunk-NSZJJPMQ.js";
42
+ } from "./chunk-BSCX74FG.js";
37
43
  import {
38
44
  MANAGEMENT_NET,
39
45
  REALM_HOME_PROXY_CONTAINER_PATH,
@@ -62,6 +68,8 @@ import "./chunk-MLKGABMK.js";
62
68
  export {
63
69
  BACKUP_FILE_EXISTS,
64
70
  BACKUP_FILE_TYPES,
71
+ BACKUP_PATHS_MAX,
72
+ BACKUP_READ_ITEMS_MAX,
65
73
  BACKUP_RESTORE_INTERRUPTED,
66
74
  BACKUP_RESTORE_PHASES,
67
75
  BACKUP_SAFETY_TAG,
@@ -81,6 +89,9 @@ export {
81
89
  REALM_STATS_SOCKET,
82
90
  SILO_NET_PREFIX,
83
91
  UNTRUSTED_NET,
92
+ backupLsRequestSchema,
93
+ backupLsResultSchema,
94
+ backupReadRequestSchema,
84
95
  backupRestoreRequestSchema,
85
96
  backupSignRequestSchema,
86
97
  backupSignResultSchema,
@@ -94,6 +105,7 @@ export {
94
105
  homeProxyMount,
95
106
  homeProxySocketPath,
96
107
  ingressInputSchema,
108
+ isBackupRelPath,
97
109
  isManagedSiloPath,
98
110
  isPreparedAppDir,
99
111
  isSiloNetwork,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yourrealm/sdk",
3
- "version": "0.0.0-alpha.1451.8a8a7d44",
3
+ "version": "0.0.0-alpha.1471.2b6cf8b8",
4
4
  "description": "The Realm SDK: createApp, the r.* permission factories, the ui surface, the realm-sdk CLI and test harness.",
5
5
  "license": "AGPL-3.0-only WITH Classpath-exception-2.0",
6
6
  "repository": {
@@ -106,9 +106,9 @@
106
106
  "@std/path": "npm:@jsr/std__path@^1",
107
107
  "@std/tar": "npm:@jsr/std__tar@^0.1",
108
108
  "@std/testing": "npm:@jsr/std__testing@^1",
109
- "@local/docker": "0.1.0",
110
109
  "@local/logger": "0.1.0",
111
- "@yourrealm/noise": "0.1.0"
110
+ "@yourrealm/noise": "0.1.0",
111
+ "@local/docker": "0.1.0"
112
112
  },
113
113
  "scripts": {
114
114
  "typecheck": "deno check",
@@ -22,13 +22,14 @@
22
22
 
23
23
  import type { BuildRecipe } from "../../service-definition.ts";
24
24
 
25
- // Default builder image: canonical kaniko. It runs as root and never calls
26
- // unshare(CLONE_NEWUSER), so it builds clean under the broker's envelope:
25
+ // Default builder image: our build of Chainguard's kaniko fork
26
+ // (publish-kaniko.yml), since upstream is archived. It runs as root and never
27
+ // calls unshare(CLONE_NEWUSER), so it builds clean under the broker's envelope:
27
28
  // HARDENING plus the file-extraction caps (FOWNER, DAC_OVERRIDE, SETFCAP,
28
29
  // FSETID) spec.ts adds so base rootfs with non-root-owned files
29
30
  // (node:22-alpine's /home/node) unpack faithfully — the property is
30
31
  // kaniko's, not a specific distro's.
31
- export const DEFAULT_KANIKO_IMAGE = "gcr.io/kaniko-project/executor:latest";
32
+ export const DEFAULT_KANIKO_IMAGE = "benjick/realm-kaniko:v1.25.19";
32
33
 
33
34
  // Where the broker bind-mounts the staged context, read-only, in the box.
34
35
  // Must match `CONTEXT_MOUNT` in spawn-server/spec.ts — kaniko.test.ts pins
package/src/sandbox.ts CHANGED
@@ -1241,14 +1241,21 @@ export async function runGate(
1241
1241
  // ---- widget prefill (loader request stitching) -----------------------------
1242
1242
 
1243
1243
  // Run every loader's request(seedState, config) inside the VM and collect the
1244
- // descriptors. `seedState` is the surface's `stateSchema.parse({})` — the exact
1245
- // seed the iframe runtime mounts with — so a descriptor produced here matches
1244
+ // descriptors. `seedState` follows `seedFrom` in ui-runtime/mount.ts — the page
1245
+ // query parsed through `stateSchema`, else its `{}`-parse, else `{}` — the exact
1246
+ // seed the iframe runtime mounts with, so a descriptor produced here matches
1246
1247
  // the query key the runtime later builds from `request(state, config)`, and the
1247
1248
  // prefilled data seeds cleanly with no first-paint refetch. A loader whose
1248
1249
  // request is null (or throws) on the seed is lazy for this config → omitted.
1249
- function stitchSnippet(collection: "pages" | "widgets", key: string, config: unknown): string {
1250
+ function stitchSnippet(
1251
+ collection: "pages" | "widgets",
1252
+ key: string,
1253
+ config: unknown,
1254
+ search: Record<string, string> | undefined,
1255
+ ): string {
1250
1256
  return [
1251
1257
  `var __config = ${JSON.stringify(config ?? null)};`,
1258
+ `var __search = ${JSON.stringify(search ?? {})};`,
1252
1259
  `if (__config == null) __config = {};`, // mirror the runtime's `opts.config ?? {}`
1253
1260
  `(function () {`,
1254
1261
  ` var ui = __app.ui;`,
@@ -1256,7 +1263,8 @@ function stitchSnippet(collection: "pages" | "widgets", key: string, config: unk
1256
1263
  ` if (!surface) return JSON.stringify({});`,
1257
1264
  ` var seedState = {};`,
1258
1265
  ` if (surface.stateSchema && typeof surface.stateSchema.safeParse === "function") {`,
1259
- ` var __p = surface.stateSchema.safeParse({});`,
1266
+ ` var __q = surface.stateSchema.safeParse(__search);`,
1267
+ ` var __p = __q.success ? __q : surface.stateSchema.safeParse({});`,
1260
1268
  ` seedState = __p.success ? __p.data : {};`,
1261
1269
  ` }`,
1262
1270
  ` var out = {};`,
@@ -1289,10 +1297,12 @@ export async function stitchLoaderRequests(opts: {
1289
1297
  kind: "page" | "widget";
1290
1298
  key: string;
1291
1299
  config: unknown;
1300
+ // A page's URL query (see `seedFrom` in ui-runtime/mount.ts). Absent → `{}`.
1301
+ search?: Record<string, string>;
1292
1302
  }): Promise<Record<string, RequestManifest | null>> {
1293
1303
  const vm = getVm({ key: `prefill:${opts.appId}:${opts.bundleId}`, bundle: opts.bundle });
1294
1304
  const collection = opts.kind === "widget" ? "widgets" : "pages";
1295
- const result = await vm.call(stitchSnippet(collection, opts.key, opts.config));
1305
+ const result = await vm.call(stitchSnippet(collection, opts.key, opts.config, opts.search));
1296
1306
  return (result ?? {}) as Record<string, RequestManifest | null>;
1297
1307
  }
1298
1308
 
@@ -39,6 +39,14 @@ export { PiEnvelopeDuotone as EnvelopeIcon } from "react-icons/pi";
39
39
  export { PiEyeDuotone as EyeIcon } from "react-icons/pi";
40
40
  export { PiEyeSlashDuotone as EyeSlashIcon } from "react-icons/pi";
41
41
  export { PiFadersDuotone as FadersIcon } from "react-icons/pi";
42
+ export { PiFileDuotone as FileIcon } from "react-icons/pi";
43
+ export { PiFileAudioDuotone as FileAudioIcon } from "react-icons/pi";
44
+ export { PiFileCodeDuotone as FileCodeIcon } from "react-icons/pi";
45
+ export { PiFileImageDuotone as FileImageIcon } from "react-icons/pi";
46
+ export { PiFilePdfDuotone as FilePdfIcon } from "react-icons/pi";
47
+ export { PiFileTextDuotone as FileTextIcon } from "react-icons/pi";
48
+ export { PiFileVideoDuotone as FileVideoIcon } from "react-icons/pi";
49
+ export { PiFileZipDuotone as FileZipIcon } from "react-icons/pi";
42
50
  export { PiFolderOpenDuotone as FolderOpenIcon } from "react-icons/pi";
43
51
  export { PiFolderSimpleDuotone as FolderSimpleIcon } from "react-icons/pi";
44
52
  export { PiGaugeDuotone as GaugeIcon } from "react-icons/pi";
@@ -67,11 +67,15 @@ interface RenderApi {
67
67
  queryClient: QueryClient;
68
68
  }
69
69
 
70
+ interface StateSchemaLike {
71
+ safeParse(v: unknown): { success: boolean; data?: unknown };
72
+ }
73
+
70
74
  interface SurfaceLike {
71
75
  title: string;
72
- // The live state schema rides the bundle; its `{}`-parse seeds the
73
- // widget's initial state.
74
- stateSchema?: { safeParse(v: unknown): { success: boolean; data?: unknown } };
76
+ // The live state schema rides the bundle and seeds the initial state —
77
+ // see `seedFrom`.
78
+ stateSchema?: StateSchemaLike;
75
79
  loaders?: Record<string, SurfaceLoader>;
76
80
  actions?: Record<string, SurfaceAction>;
77
81
  render: (api: RenderApi) => ComponentChildren;
@@ -95,6 +99,9 @@ interface MountOpts {
95
99
  // as the loader's queryFn output *before* merge, so the runtime applies
96
100
  // merge to it too — see `applyMerge`.
97
101
  initialData?: Record<string, unknown>;
102
+ // A page's URL query, flat (last occurrence wins), inlined by Home. Seeds
103
+ // the initial state — see `seedFrom`. Widgets never get one.
104
+ search?: Record<string, string>;
98
105
  }
99
106
 
100
107
  interface PendingHandler {
@@ -201,6 +208,21 @@ function applyMerge(loader: SurfaceLoader, raw: unknown): unknown {
201
208
  return raw;
202
209
  }
203
210
 
211
+ // The surface's initial state: the page query parsed through `stateSchema`,
212
+ // else the `{}`-parse, else `{}`. A query that fails to parse is dropped
213
+ // whole, never key by key. Mirrored by `stitchSnippet` in sandbox.ts, so
214
+ // Home's prefill runs `request()` on this exact seed — keep the two in step.
215
+ export function seedFrom(
216
+ schema: StateSchemaLike | undefined,
217
+ search: Record<string, string> | undefined,
218
+ ): unknown {
219
+ if (!schema) return {};
220
+ const fromQuery = schema.safeParse(search ?? {});
221
+ if (fromQuery.success) return fromQuery.data;
222
+ const empty = schema.safeParse({});
223
+ return empty.success ? empty.data : {};
224
+ }
225
+
204
226
  // Assemble the render fn's props from the handles built this render. Pure, so
205
227
  // the prop wiring is testable without a DOM.
206
228
  export function renderApi(args: {
@@ -240,14 +262,13 @@ export function mount(opts: MountOpts): void {
240
262
  },
241
263
  });
242
264
 
243
- // The widget's config (resolved by Home) and initial state (the live
244
- // stateSchema's `{}`-parse). `seedState` is a stable reference so
265
+ // The widget's config (resolved by Home) and initial state (`seedFrom`).
266
+ // `seedState` is a stable reference so
245
267
  // `initialData` applies only while the widget is still at its seed: that
246
268
  // gates the prefill to the query key Home actually ran to produce it
247
269
  // (`request(seedState, config)`), so a re-keyed loader can't inherit it.
248
270
  const config = opts.config ?? {};
249
- const seedParse = opts.surface.stateSchema?.safeParse({});
250
- const seedState: unknown = seedParse?.success ? seedParse.data : {};
271
+ const seedState = seedFrom(opts.surface.stateSchema, opts.search);
251
272
 
252
273
  // Capture the loader/action name sets ONCE, before the component
253
274
  // renders. The render loop calls one useQuery per loader and one
@@ -1,6 +1,9 @@
1
1
  import { log } from "@local/logger";
2
2
  import type { WorkerCapability } from "../permissions.ts";
3
3
  import type {
4
+ BackupLsRequest,
5
+ BackupLsResult,
6
+ BackupReadRequest,
4
7
  BackupRestoreRequest,
5
8
  BackupSignRequest,
6
9
  BackupSignResult,
@@ -10,7 +13,13 @@ import type {
10
13
  } from "../worker-protocol/index.ts";
11
14
  import {
12
15
  type AppApplyResult,
16
+ BACKUP_READ_FILE_TYPE,
17
+ BACKUP_READ_SIZE_HEADER,
18
+ BACKUP_READ_TAR_TYPE,
19
+ BACKUP_READ_TYPE_HEADER,
20
+ type BackupRead,
13
21
  jsonHandler,
22
+ paramsHandler,
14
23
  parseEnrollmentToken,
15
24
  type RpcHandler,
16
25
  type UpdateProject,
@@ -76,6 +85,18 @@ export interface StartWorkerOptions {
76
85
  * it is in progress).
77
86
  */
78
87
  backupRelease: (appId: string) => Promise<void>;
88
+ /**
89
+ * Home → worker `backup.ls`: one folder of a snapshot. Throw a `MethodError`
90
+ * to refuse (404 not there, 503 locked or no bucket).
91
+ */
92
+ backupLs: (req: BackupLsRequest) => Promise<BackupLsResult>;
93
+ /**
94
+ * Home → worker `backup.read`: the bytes of paths in a snapshot, as one file
95
+ * or a tar. Resolves once the read is known to be answerable, with the body
96
+ * unread; cancelling the body must stop the read. Throw a `MethodError` to
97
+ * refuse (404 not there, 503 locked or no bucket).
98
+ */
99
+ backupRead: (req: BackupReadRequest) => Promise<BackupRead>;
79
100
  /**
80
101
  * What this box's ingress container is running, or null where it has none.
81
102
  * Injected rather than read here, for the reason the whole `worker` layer is
@@ -214,6 +235,18 @@ export async function startWorker(opts: StartWorkerOptions): Promise<WorkerHandl
214
235
  await opts.backupRelease(appId);
215
236
  return { ok: true };
216
237
  }),
238
+ "backup.ls": jsonHandler("backup.ls", (req) => opts.backupLs(req)),
239
+ "backup.read": paramsHandler("backup.read", async (req) => {
240
+ const read = await opts.backupRead(req);
241
+ const headers: Record<string, string> =
242
+ read.kind === "file"
243
+ ? {
244
+ [BACKUP_READ_TYPE_HEADER]: BACKUP_READ_FILE_TYPE,
245
+ [BACKUP_READ_SIZE_HEADER]: String(read.size ?? 0),
246
+ }
247
+ : { [BACKUP_READ_TYPE_HEADER]: BACKUP_READ_TAR_TYPE };
248
+ return { status: 200, headers, body: read.body };
249
+ }),
217
250
  uiProxy: uiProxyHandler((appId, service) =>
218
251
  lastState === null ? undefined : declaredPortsIn(lastState, appId, service),
219
252
  ),
@@ -297,23 +297,121 @@ const snapshotGroupSchema = z.object({
297
297
  offsite: z.array(snapshotSchema).nullable(),
298
298
  });
299
299
 
300
- // --- backup.restore --------------------------------------------------------
300
+ // --- paths inside a snapshot -----------------------------------------------
301
+
302
+ /** Longest path inside a volume the wire carries, in UTF-16 units. */
303
+ const BACKUP_PATH_MAX = 4096;
304
+
305
+ /**
306
+ * A path inside a volume, relative to its root: `a/b.txt`, never `/a`, `a/`,
307
+ * `a//b`, `.`, `..` or a NUL. The Worker checks it again where it joins it.
308
+ */
309
+ export function isBackupRelPath(p: string): boolean {
310
+ return (
311
+ p.length > 0 &&
312
+ p.length <= BACKUP_PATH_MAX &&
313
+ !p.includes("\0") &&
314
+ p.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..")
315
+ );
316
+ }
317
+
318
+ const relPathSchema = z.string().refine(isBackupRelPath, {
319
+ message: "must be a relative path with no empty, . or .. segments and no NUL",
320
+ });
321
+
322
+ /** Most paths one read or put-back names. */
323
+ export const BACKUP_PATHS_MAX = 10_000;
324
+
325
+ const relPathsSchema = z
326
+ .array(relPathSchema)
327
+ .min(1)
328
+ .max(BACKUP_PATHS_MAX)
329
+ .refine((ps) => new Set(ps).size === ps.length, { message: "a path is named twice" });
301
330
 
302
331
  const restoreSourceSchema = z.enum(["local", "offsite"]);
303
332
 
304
- const restoreItemSchema = z.object({
333
+ /** The one snapshot of one volume a listing, a read or a restore item names. */
334
+ const snapshotRefSchema = z.object({
305
335
  serviceKey: z.string().min(1),
306
336
  volume: backupVolumeSchema,
307
337
  snapshotId: z.string().regex(SNAPSHOT_ID_RE),
308
338
  source: restoreSourceSchema,
309
339
  });
310
340
 
341
+ // --- backup.ls ---------------------------------------------------------------
342
+
343
+ /**
344
+ * `backup.ls`, Home → Worker: one folder of one snapshot of one volume.
345
+ * `path` is relative to the volume's root, `""` for the root itself. A
346
+ * single-file volume (sqlite, pg-dump, mysql-dump, valkey-save) lists its one
347
+ * file at the root and has no folders.
348
+ */
349
+ export const backupLsRequestSchema = snapshotRefSchema.extend({
350
+ appId: z.string().min(1),
351
+ path: z.union([z.literal(""), relPathSchema]),
352
+ });
353
+ export type BackupLsRequest = z.infer<typeof backupLsRequestSchema>;
354
+
355
+ const lsEntrySchema = z.object({
356
+ name: z.string().min(1),
357
+ type: z.enum(["dir", "file", "symlink"]),
358
+ /** Bytes; files only. */
359
+ size: z.number().int().nonnegative().optional(),
360
+ /** ISO-8601, UTC. */
361
+ mtime: z.string(),
362
+ });
363
+ export type BackupLsEntry = z.infer<typeof lsEntrySchema>;
364
+
365
+ /** The folder's entries, sorted by name. */
366
+ export const backupLsResultSchema = z.object({ entries: z.array(lsEntrySchema) });
367
+ export type BackupLsResult = z.infer<typeof backupLsResultSchema>;
368
+
369
+ // --- backup.read -------------------------------------------------------------
370
+
371
+ /** Most volumes one read names. */
372
+ export const BACKUP_READ_ITEMS_MAX = 100;
373
+
374
+ /**
375
+ * `backup.read`, Home → Worker: the bytes of one selection per volume of one
376
+ * app. An item's `paths` are relative to its volume's root, files and folders
377
+ * from anywhere in it; absent, the whole volume. Exactly one item naming
378
+ * exactly one file answers that file as itself; anything else, one tar. With
379
+ * one item its names are relative to the volume's root; with several, each
380
+ * item's sit under a top folder named after its volume, or
381
+ * `<serviceKey>-<volume>` where two services share a volume name.
382
+ */
383
+ export const backupReadRequestSchema = z.object({
384
+ appId: z.string().min(1),
385
+ items: z
386
+ .array(snapshotRefSchema.extend({ paths: relPathsSchema.optional() }))
387
+ .min(1)
388
+ .max(BACKUP_READ_ITEMS_MAX)
389
+ .refine(
390
+ (items) => new Set(items.map((i) => `${i.serviceKey}/${i.volume}`)).size === items.length,
391
+ { message: "a volume is named twice" },
392
+ ),
393
+ });
394
+ export type BackupReadRequest = z.infer<typeof backupReadRequestSchema>;
395
+ export type BackupReadItem = BackupReadRequest["items"][number];
396
+
397
+ // --- backup.restore --------------------------------------------------------
398
+
399
+ const restoreItemSchema = snapshotRefSchema.extend({
400
+ /**
401
+ * Put back only these, relative to the volume's root: each becomes exactly
402
+ * the snapshot's version, and nothing else in the volume moves. Folder
403
+ * (file-copy) volumes only. Absent, the whole volume.
404
+ */
405
+ paths: relPathsSchema.optional(),
406
+ });
407
+
311
408
  /**
312
409
  * `backup.restore`, Home → Worker: put these snapshots back over one app's
313
410
  * volumes. The Worker answers once it has accepted the restore - 404 an
314
- * unknown app or volume, 409 a restore of that app already queued or running,
315
- * 503 an offsite item with no bucket set - and does the work afterwards,
316
- * reporting through `backup.status`.
411
+ * unknown app or volume, or an item's path the snapshot doesn't hold, 400
412
+ * `paths` on a volume that isn't a folder, 409 a restore of that app already
413
+ * queued or running, 503 an offsite item with no bucket set - and does the
414
+ * work afterwards, reporting through `backup.status`.
317
415
  */
318
416
  export const backupRestoreRequestSchema = z.object({
319
417
  restoreId: z.uuid(),
@@ -15,14 +15,20 @@ export { ingressInputSchema, PLATFORM_INGRESS_PORTS } from "./ingress.ts";
15
15
  export {
16
16
  BACKUP_FILE_EXISTS,
17
17
  BACKUP_FILE_TYPES,
18
+ BACKUP_PATHS_MAX,
19
+ BACKUP_READ_ITEMS_MAX,
18
20
  BACKUP_RESTORE_INTERRUPTED,
19
21
  BACKUP_RESTORE_PHASES,
20
22
  BACKUP_SAFETY_TAG,
21
23
  BACKUP_STALE_GENERATION,
24
+ backupLsRequestSchema,
25
+ backupLsResultSchema,
26
+ backupReadRequestSchema,
22
27
  backupRestoreRequestSchema,
23
28
  backupSnapshotTags,
24
29
  backupTargetKey,
25
30
  backupVolumeSchema,
31
+ isBackupRelPath,
26
32
  parseBackupSnapshotTags,
27
33
  backupSignRequestSchema,
28
34
  backupSignResultSchema,
@@ -31,6 +37,11 @@ export {
31
37
  } from "./backup.ts";
32
38
  export type {
33
39
  BackupFileType,
40
+ BackupLsEntry,
41
+ BackupLsRequest,
42
+ BackupLsResult,
43
+ BackupReadItem,
44
+ BackupReadRequest,
34
45
  BackupRepoStatus,
35
46
  BackupRestore,
36
47
  BackupRestoreItem,
@@ -17,12 +17,18 @@ export { PROTOCOL_VERSION } from "./protocol.ts";
17
17
  export {
18
18
  APPLY_MESSAGE_MAX_CHARS,
19
19
  appApplyResultSchema,
20
+ BACKUP_READ_FILE_TYPE,
21
+ BACKUP_READ_SIZE_HEADER,
22
+ BACKUP_READ_TAR_TYPE,
23
+ BACKUP_READ_TYPE_HEADER,
24
+ callBackupRead,
20
25
  callJson,
21
26
  callUiProxy,
22
27
  ETAG_HEADER,
23
28
  jsonHandler,
24
29
  MethodError,
25
30
  methods,
31
+ paramsHandler,
26
32
  pullState,
27
33
  UPDATE_ERROR_MAX_CHARS,
28
34
  UPDATE_PROJECTS,
@@ -30,6 +36,7 @@ export {
30
36
  } from "./methods.ts";
31
37
  export type {
32
38
  AppApplyResult,
39
+ BackupRead,
33
40
  JsonMethod,
34
41
  MethodName,
35
42
  Params,
@@ -13,6 +13,9 @@ import {
13
13
  homeProxyResponseSchema,
14
14
  } from "../../runtime/docker/home-proxy-server/protocol.ts";
15
15
  import {
16
+ backupLsRequestSchema,
17
+ backupLsResultSchema,
18
+ backupReadRequestSchema,
16
19
  backupRestoreRequestSchema,
17
20
  backupSignRequestSchema,
18
21
  backupSignResultSchema,
@@ -23,11 +26,20 @@ import type { SiloOrphan } from "../silo-paths.ts";
23
26
  import { UPDATE_ERROR_MAX_CHARS } from "../update-files.ts";
24
27
  import { UplinkError, UplinkProtocolError } from "./errors.ts";
25
28
  import { decodeUtf8, encodeUtf8 } from "./frame.ts";
26
- import { jsonResponse, type Rpc, type RpcHandler } from "./rpc.ts";
29
+ import { jsonResponse, type Rpc, type RpcHandler, type RpcResponse } from "./rpc.ts";
27
30
 
28
31
  /** The response header `state.pull` answers a 200 with. */
29
32
  export const ETAG_HEADER = "etag";
30
33
 
34
+ /**
35
+ * `backup.read`'s 200: `content-type` says what the body is - one file as
36
+ * itself, or a tar - and a file's carries its size in `content-length`.
37
+ */
38
+ export const BACKUP_READ_TYPE_HEADER = "content-type";
39
+ export const BACKUP_READ_SIZE_HEADER = "content-length";
40
+ export const BACKUP_READ_FILE_TYPE = "application/octet-stream";
41
+ export const BACKUP_READ_TAR_TYPE = "application/x-tar";
42
+
31
43
  // --- shared pieces ------------------------------------------------------
32
44
 
33
45
  const capabilitiesSchema = z.array(
@@ -317,16 +329,43 @@ export const methods = {
317
329
  },
318
330
 
319
331
  /**
320
- * Home → worker. Restore snapshots over one app's volumes. Answered once the
321
- * worker has accepted it - 404 unknown app or volume, 409 a restore of that
322
- * app already in progress, 503 an offsite item with no bucket - and run
323
- * afterwards; progress rides `backup.status`.
332
+ * Home → worker. Restore snapshots over one app's volumes, or only some
333
+ * paths of a folder volume. Answered once the worker has accepted it - 404
334
+ * unknown app or volume or a path the snapshot doesn't hold, 400 `paths` on
335
+ * a volume that isn't a folder, 409 a restore of that app already in
336
+ * progress, 503 an offsite item with no bucket - and run afterwards;
337
+ * progress rides `backup.status`.
324
338
  */
325
339
  "backup.restore": {
326
340
  params: backupRestoreRequestSchema,
327
341
  result: okSchema,
328
342
  },
329
343
 
344
+ /**
345
+ * Home → worker. One folder of a snapshot: 404 an unknown app, volume,
346
+ * snapshot or folder, 503 the repository locked by a prune past the wait or
347
+ * an offsite source with no bucket. Read under restic's shared lock, beside
348
+ * any backup run rather than after it.
349
+ */
350
+ "backup.ls": {
351
+ params: backupLsRequestSchema,
352
+ result: backupLsResultSchema,
353
+ },
354
+
355
+ /**
356
+ * Home → worker. The bytes of a selection from one or more volumes' snapshots,
357
+ * streamed: one item naming one file as itself (`BACKUP_READ_FILE_TYPE`, its
358
+ * size in `content-length`), anything else one tar (`BACKUP_READ_TAR_TYPE`).
359
+ * One item's names are relative to its volume's root; several items' sit
360
+ * under a top folder each (`backupReadRequestSchema`). Every item is checked
361
+ * before the first byte: 404 an unknown app, volume, snapshot or path, 400 a
362
+ * malformed request, 503 as `backup.ls`. Cancelling the body stops the read
363
+ * on the worker.
364
+ */
365
+ "backup.read": {
366
+ params: backupReadRequestSchema,
367
+ },
368
+
330
369
  /**
331
370
  * Home → worker. Release an app a restore left held (`BackupStatusReport.held`)
332
371
  * and start it on its data as it is: 404 when it isn't held so, 409 while a
@@ -450,6 +489,47 @@ export async function callUiProxy(rpc: Rpc, payload: Params<"uiProxy">): Promise
450
489
  return { status: res.status, headers: res.headers, body: await readAll(res.body) };
451
490
  }
452
491
 
492
+ export interface BackupRead {
493
+ /** `file`: the one file's bytes; `tar`: an archive of the selection. */
494
+ kind: "file" | "tar";
495
+ /** A file's size in bytes. */
496
+ size?: number;
497
+ /** Unread. The caller drains or cancels it; cancelling stops the worker's read. */
498
+ body: ReadableStream<Uint8Array>;
499
+ }
500
+
501
+ /**
502
+ * `backup.read`, whose 200 body is the bytes. A non-2xx throws `MethodError`
503
+ * with the worker's text, as `callJson` does.
504
+ */
505
+ export async function callBackupRead(
506
+ rpc: Rpc,
507
+ params: Params<"backup.read">,
508
+ opts?: { signal?: AbortSignal },
509
+ ): Promise<BackupRead> {
510
+ const res = await rpc.call(
511
+ "backup.read",
512
+ {},
513
+ encodeJson(methods["backup.read"].params, params),
514
+ opts,
515
+ );
516
+ if (res.status < 200 || res.status >= 300) {
517
+ throw new MethodError(
518
+ "backup.read",
519
+ res.status,
520
+ errorText(decodeUtf8(await readAll(res.body))),
521
+ );
522
+ }
523
+ const type = res.headers[BACKUP_READ_TYPE_HEADER];
524
+ if (type === BACKUP_READ_TAR_TYPE) return { kind: "tar", body: res.body };
525
+ const size = Number(res.headers[BACKUP_READ_SIZE_HEADER]);
526
+ if (type !== BACKUP_READ_FILE_TYPE || !Number.isSafeInteger(size) || size < 0) {
527
+ await res.body.cancel().catch(() => {});
528
+ throw new UplinkProtocolError("backup.read answered without a body type and size");
529
+ }
530
+ return { kind: "file", size, body: res.body };
531
+ }
532
+
453
533
  // --- answering ----------------------------------------------------------
454
534
 
455
535
  /**
@@ -483,6 +563,37 @@ export function jsonHandler<M extends JsonMethod>(
483
563
  };
484
564
  }
485
565
 
566
+ /**
567
+ * Wraps a handler for a method whose params are JSON and whose answer is
568
+ * whatever the handler builds - a streamed body. The params are checked as
569
+ * `jsonHandler` checks them (400); a `MethodError` thrown before the answer
570
+ * is answered as `{"error": body}` with its status.
571
+ */
572
+ export function paramsHandler<M extends MethodName>(
573
+ method: M,
574
+ fn: (params: Params<M>) => Promise<RpcResponse>,
575
+ ): RpcHandler {
576
+ const spec = methods[method] as { params: z.ZodType };
577
+ return async (req) => {
578
+ let raw: unknown;
579
+ try {
580
+ raw = JSON.parse(decodeUtf8(await readAll(req.body)));
581
+ } catch {
582
+ return jsonResponse(400, { error: `${method}: body is not JSON` });
583
+ }
584
+ const parsed = spec.params.safeParse(raw);
585
+ if (!parsed.success) {
586
+ return jsonResponse(400, { error: `${method}: ${issueText(parsed.error)}` });
587
+ }
588
+ try {
589
+ return await fn(parsed.data as Params<M>);
590
+ } catch (e) {
591
+ if (e instanceof MethodError) return jsonResponse(e.status, { error: e.body });
592
+ throw e;
593
+ }
594
+ };
595
+ }
596
+
486
597
  // --- helpers ------------------------------------------------------------
487
598
 
488
599
  function encodeJson(schema: z.ZodType, params: unknown): Uint8Array {
@@ -8,4 +8,4 @@
8
8
  * door both sides can always open is what lets a mismatched pair be told to
9
9
  * update instead of being stranded.
10
10
  */
11
- export const PROTOCOL_VERSION = 3;
11
+ export const PROTOCOL_VERSION = 4;