@pithy-sh/storage 0.1.0

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,313 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
7
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
9
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
10
+ import type { Context, Hono } from "hono";
11
+ import type { StorageConfig } from "../config/config";
12
+ import { storageDatabase } from "../data/tables";
13
+ import { objectStore } from "../object/store";
14
+ import { STORAGE_R2_SECRET } from "../secret/registry";
15
+ import { requireAuth } from "./guard";
16
+ import {
17
+ abortUpload,
18
+ completeUpload,
19
+ copyObject,
20
+ createShare,
21
+ deleteObject,
22
+ type HandlerDeps,
23
+ initUpload,
24
+ listObjects,
25
+ listUploadParts,
26
+ presignObject,
27
+ readableObject,
28
+ resolveShare,
29
+ revokeShare,
30
+ updateObject,
31
+ } from "./handlers";
32
+ import {
33
+ CompleteUploadInput,
34
+ CopyObjectInput,
35
+ CreateShareInput,
36
+ CreateUploadInput,
37
+ ListObjectsQuery,
38
+ ObjectIdParam,
39
+ ObjectReadQuery,
40
+ ShareTokenParam,
41
+ UpdateObjectInput,
42
+ } from "./schemas";
43
+ import {
44
+ parseConditions,
45
+ parseRangeHeader,
46
+ rangeNotSatisfiable,
47
+ serveMetadata,
48
+ serveObject,
49
+ toObjectRange,
50
+ } from "./serve";
51
+
52
+ /**
53
+ * The storage routes, their declared verification strategies, and what each accepts:
54
+ *
55
+ * POST /storage → start an upload (bearer | session)
56
+ * json: CreateUploadInput
57
+ * GET /storage → list your files (bearer | session, owner-scoped)
58
+ * query: ListObjectsQuery
59
+ * DELETE /storage/shares/:token → revoke a share (bearer | session, owner-scoped)
60
+ * param: ShareTokenParam
61
+ * POST /storage/:id/complete → finalize a multipart (bearer | session, owner-scoped)
62
+ * param: ObjectIdParam, json: CompleteUploadInput
63
+ * POST /storage/:id/abort → abandon an upload (bearer | session, owner-scoped)
64
+ * param: ObjectIdParam — no body
65
+ * GET /storage/:id/parts → resume a multipart (bearer | session, owner-scoped)
66
+ * param: ObjectIdParam
67
+ * POST /storage/:id/copy → server-side copy (bearer | session, owner-scoped)
68
+ * param: ObjectIdParam, json: CopyObjectInput
69
+ * POST /storage/:id/shares → mint a revocable share (bearer | session, owner-scoped)
70
+ * param: ObjectIdParam, json: CreateShareInput
71
+ * GET /storage/:id/url → presigned direct URL (bearer | session, owner-or-public)
72
+ * param: ObjectIdParam
73
+ * GET /storage/:id → stream the bytes (public when `visibility: 'public'`,
74
+ * otherwise bearer | session + owner)
75
+ * param: ObjectIdParam, query: ObjectReadQuery
76
+ * HEAD /storage/:id → metadata only (bearer | session, owner-scoped)
77
+ * param: ObjectIdParam
78
+ * PATCH /storage/:id → rename or change access (bearer | session, owner-scoped)
79
+ * param: ObjectIdParam, json: UpdateObjectInput
80
+ * DELETE /storage/:id → delete file and row (bearer | session, owner-scoped)
81
+ * param: ObjectIdParam
82
+ * GET /s/:token → fetch via a share link (public — the token is the credential)
83
+ * param: ShareTokenParam, query: ObjectReadQuery
84
+ *
85
+ * **Every validator sits after the guard.** An unauthenticated request with a malformed body is a
86
+ * 401, never a 400 — shape is not something a caller learns before being verified. `POST
87
+ * /storage/:id/abort` carries no json validator because it reads no body: adding one would 400 the
88
+ * bodyless POST every client sends today.
89
+ *
90
+ * `GET /storage/:id` is the one route with two strategies, because a public file must be readable by
91
+ * someone with no session at all. It is therefore **not** wrapped in {@link requireAuth}: the guard
92
+ * would deny before the handler could see that the object is public. Authorization happens inside the
93
+ * handler instead, against `c.var.auth` — which is `null` with no auth capability composed, so a
94
+ * private object is denied by default and only an explicitly public one is served.
95
+ *
96
+ * **Static segments are registered before `:param` ones.** Hono matches in registration order, so
97
+ * `/storage/shares/:token` has to come before `/storage/:id` or a DELETE to `/storage/shares/abc`
98
+ * would be read as deleting the object whose id is `shares`.
99
+ *
100
+ * **Uploads never proxy bytes through the Worker; downloads do.** That asymmetry is the design, not
101
+ * an inconsistency — `serve.ts` carries the reasoning.
102
+ */
103
+ export interface StorageRoutesOptions {
104
+ /** The resolved storage config. */
105
+ config: StorageConfig;
106
+ /** Where the object routes mount. Defaults to `/storage`. */
107
+ basePath?: string;
108
+ /** Where share fetches mount. Defaults to `/s` — short, because the whole URL gets pasted around. */
109
+ sharePath?: string;
110
+ /** Test seam: resolve handler deps from the request. Defaults to the env-based resolver. */
111
+ resolveDeps?: (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
112
+ }
113
+
114
+ /** The bindings the storage routes read off the Worker env. */
115
+ interface StorageEnv extends SecretsStoreEnv {
116
+ /** The app database the `pithy_storage_*` tables live in. */
117
+ DB: D1Database;
118
+ /** The bucket objects are stored in. */
119
+ STORAGE_BUCKET: R2Bucket;
120
+ }
121
+
122
+ /**
123
+ * Read one binding, or fail with a message naming the binding and how to add it. A `?.` here would
124
+ * turn a missing binding into a `TypeError` at the first query, attributed to nothing.
125
+ */
126
+ function requireBinding<T>(env: unknown, name: string, kind: string): T {
127
+ const value = (env as Record<string, unknown>)[name];
128
+ if (!value) {
129
+ throw new InternalError({
130
+ message: "Storage is not configured.",
131
+ action: `Bind ${kind} named ${name} in wrangler.jsonc, then redeploy.`,
132
+ detail: `@pithy-sh/storage requires the ${name} binding; none was present on env.`,
133
+ });
134
+ }
135
+ return value as T;
136
+ }
137
+
138
+ /** Build the default per-request dependency resolver from the resolved config. */
139
+ function defaultResolveDeps(config: StorageConfig): (c: Context<PithyHonoEnv>) => Promise<HandlerDeps> {
140
+ return async (c) => {
141
+ const env = c.env as unknown as StorageEnv;
142
+ const bucket = requireBinding<R2Bucket>(env, "STORAGE_BUCKET", "an R2 bucket");
143
+ const d1 = requireBinding<D1Database>(env, "DB", "a D1 database");
144
+ return {
145
+ db: storageDatabase(d1),
146
+ // Credentials resolve lazily inside the store, so a request that only reads bytes through the
147
+ // binding never touches the secrets store.
148
+ store: objectStore({ bucket, env, secretName: STORAGE_R2_SECRET }),
149
+ config,
150
+ ownerId: c.var.auth?.userId ?? null,
151
+ newId: () => crypto.randomUUID(),
152
+ // 32 bytes of CSPRNG output, base64url. The token *is* the credential for a share link, so its
153
+ // entropy is the only thing standing between a link and anyone guessing one.
154
+ newToken: () => shareToken(),
155
+ now: () => new Date(),
156
+ };
157
+ };
158
+ }
159
+
160
+ /** A 256-bit share token, base64url-encoded and unpadded so it is safe in a path segment. */
161
+ function shareToken(): string {
162
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
163
+ const binary = String.fromCharCode(...bytes);
164
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
165
+ }
166
+
167
+ /** Register the storage sub-router. Returned as the capability's `routes` hook. */
168
+ export function registerStorageRoutes(options: StorageRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
169
+ const base = options.basePath ?? "/storage";
170
+ const share = options.sharePath ?? "/s";
171
+ const resolve = options.resolveDeps ?? defaultResolveDeps(options.config);
172
+
173
+ /** Stream one object, honoring `Range` and `If-None-Match`. Shared by the id route and the share route. */
174
+ const stream = async (
175
+ c: Context<PithyHonoEnv>,
176
+ deps: HandlerDeps,
177
+ object: Awaited<ReturnType<typeof readableObject>>,
178
+ download: boolean,
179
+ ) => {
180
+ const requested = parseRangeHeader(c.req.header("range"), object.size);
181
+ if (requested.kind === "unsatisfiable") return rangeNotSatisfiable(object.size ?? 0);
182
+
183
+ const result = await deps.store.get(object.key, {
184
+ range: toObjectRange(requested),
185
+ onlyIf: parseConditions(c.req.header("if-none-match")),
186
+ });
187
+ if (!result) {
188
+ // The row says stored but R2 has nothing. Reported as missing rather than as a 500: from the
189
+ // caller's side the file is gone, and the orphan sweep is what reconciles the two stores.
190
+ return c.notFound();
191
+ }
192
+ return serveObject({
193
+ metadata: result.metadata,
194
+ body: result.body,
195
+ range: result.range,
196
+ path: object.path,
197
+ visibility: object.visibility,
198
+ download,
199
+ });
200
+ };
201
+
202
+ return (app) => {
203
+ app.post(base, requireAuth(), zValidator("json", CreateUploadInput, validationHook), async (c) => {
204
+ return c.json(await initUpload(await resolve(c), c.req.valid("json")), 201);
205
+ });
206
+
207
+ app.get(base, requireAuth(), zValidator("query", ListObjectsQuery, validationHook), async (c) => {
208
+ return c.json(await listObjects(await resolve(c), c.req.valid("query")));
209
+ });
210
+
211
+ // Static before `:id` — see the file doc comment.
212
+ app.delete(
213
+ `${base}/shares/:token`,
214
+ requireAuth(),
215
+ zValidator("param", ShareTokenParam, validationHook),
216
+ async (c) => {
217
+ return c.json(await revokeShare(await resolve(c), c.req.valid("param").token));
218
+ },
219
+ );
220
+
221
+ app.post(
222
+ `${base}/:id/complete`,
223
+ requireAuth(),
224
+ zValidator("param", ObjectIdParam, validationHook),
225
+ zValidator("json", CompleteUploadInput, validationHook),
226
+ async (c) => {
227
+ return c.json(await completeUpload(await resolve(c), c.req.valid("param").id, c.req.valid("json")));
228
+ },
229
+ );
230
+
231
+ // No json validator: this route reads no body, and a client that sends none must keep working.
232
+ app.post(`${base}/:id/abort`, requireAuth(), zValidator("param", ObjectIdParam, validationHook), async (c) => {
233
+ return c.json(await abortUpload(await resolve(c), c.req.valid("param").id));
234
+ });
235
+
236
+ // The resume path: what R2 already holds, and a fresh URL for every part still missing. Minting
237
+ // on demand is what keeps a part URL's TTL short without making a stalled upload unrecoverable.
238
+ app.get(`${base}/:id/parts`, requireAuth(), zValidator("param", ObjectIdParam, validationHook), async (c) => {
239
+ return c.json(await listUploadParts(await resolve(c), c.req.valid("param").id));
240
+ });
241
+
242
+ app.post(
243
+ `${base}/:id/copy`,
244
+ requireAuth(),
245
+ zValidator("param", ObjectIdParam, validationHook),
246
+ zValidator("json", CopyObjectInput, validationHook),
247
+ async (c) => {
248
+ return c.json(await copyObject(await resolve(c), c.req.valid("param").id, c.req.valid("json")), 201);
249
+ },
250
+ );
251
+
252
+ app.post(
253
+ `${base}/:id/shares`,
254
+ requireAuth(),
255
+ zValidator("param", ObjectIdParam, validationHook),
256
+ zValidator("json", CreateShareInput, validationHook),
257
+ async (c) => {
258
+ return c.json(await createShare(await resolve(c), c.req.valid("param").id, c.req.valid("json")), 201);
259
+ },
260
+ );
261
+
262
+ app.get(`${base}/:id/url`, requireAuth(), zValidator("param", ObjectIdParam, validationHook), async (c) => {
263
+ return c.json(await presignObject(await resolve(c), c.req.valid("param").id));
264
+ });
265
+
266
+ // No `requireAuth`: a public object must be readable without a session. The handler authorizes.
267
+ app.get(
268
+ `${base}/:id`,
269
+ zValidator("param", ObjectIdParam, validationHook),
270
+ zValidator("query", ObjectReadQuery, validationHook),
271
+ async (c) => {
272
+ const deps = await resolve(c);
273
+ const object = await readableObject(deps, c.req.valid("param").id);
274
+ return stream(c, deps, object, c.req.valid("query").download === "1");
275
+ },
276
+ );
277
+
278
+ app.on("HEAD", `${base}/:id`, requireAuth(), zValidator("param", ObjectIdParam, validationHook), async (c) => {
279
+ const deps = await resolve(c);
280
+ const object = await readableObject(deps, c.req.valid("param").id);
281
+ const metadata = await deps.store.head(object.key);
282
+ if (!metadata) return c.notFound();
283
+ return serveMetadata({ metadata, path: object.path, visibility: object.visibility });
284
+ });
285
+
286
+ app.patch(
287
+ `${base}/:id`,
288
+ requireAuth(),
289
+ zValidator("param", ObjectIdParam, validationHook),
290
+ zValidator("json", UpdateObjectInput, validationHook),
291
+ async (c) => {
292
+ return c.json(await updateObject(await resolve(c), c.req.valid("param").id, c.req.valid("json")));
293
+ },
294
+ );
295
+
296
+ app.delete(`${base}/:id`, requireAuth(), zValidator("param", ObjectIdParam, validationHook), async (c) => {
297
+ return c.json(await deleteObject(await resolve(c), c.req.valid("param").id));
298
+ });
299
+
300
+ // Public by design: the token is the credential, and it is checked on every fetch — which is what
301
+ // makes revocation possible at all.
302
+ app.get(
303
+ `${share}/:token`,
304
+ zValidator("param", ShareTokenParam, validationHook),
305
+ zValidator("query", ObjectReadQuery, validationHook),
306
+ async (c) => {
307
+ const deps = await resolve(c);
308
+ const object = await resolveShare(deps, c.req.valid("param").token);
309
+ return stream(c, deps, object, c.req.valid("query").download === "1");
310
+ },
311
+ );
312
+ };
313
+ }
@@ -0,0 +1,214 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { StorageVisibility } from "../data/storageObject";
6
+ import { MAX_OBJECT_KEY_BYTES } from "../object/key";
7
+ import { ReportedPart } from "../object/multipart";
8
+
9
+ /**
10
+ * The request schemas for the storage routes. Every path parameter, query string and body is parsed
11
+ * here before a handler sees it (CLAUDE.md §Zod: validate at every boundary), declared on the route
12
+ * line with `zValidator(target, Schema, validationHook)` — so reading `routes.ts` tells you what a
13
+ * route accepts without opening a handler. A failure maps to `validation/invalid_input` through
14
+ * `fromZodError`.
15
+ *
16
+ * Two shapes deserve a note.
17
+ *
18
+ * **`path` is bounded and control-character-free, but otherwise unconstrained.** It is the adopter's
19
+ * logical name and never becomes an R2 key, so `../` is not a traversal — it is just an odd file
20
+ * name. What it *is* is a value that gets stored, listed, and eventually rendered somewhere, so a NUL
21
+ * or a newline in it is rejected: those break log lines, headers, and `Content-Disposition` rather
22
+ * than escaping a prefix. The length bound matches R2's key limit even though the path is not a key,
23
+ * because an unbounded text column is an easy way to fill someone's database.
24
+ *
25
+ * **`size` is required on upload-init.** A presigned PUT signs `Content-Length`, so the client must
26
+ * commit to a byte count before a URL can be minted at all. It is also what the `pending` row
27
+ * reserves against the owner's quota — a null size would reserve nothing and make the quota a
28
+ * suggestion under concurrency (see `quota/quota.ts`).
29
+ */
30
+
31
+ /**
32
+ * Whether a path is free of C0 control characters and DEL. Written as a scan rather than a regex
33
+ * because a regex spelling this needs literal control-character escapes, which Biome rejects — and
34
+ * the rule it rejects them under is a good one.
35
+ */
36
+ function isPrintablePath(value: string): boolean {
37
+ for (let index = 0; index < value.length; index += 1) {
38
+ const code = value.charCodeAt(index);
39
+ if (code < 0x20 || code === 0x7f) return false;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ /** The logical path field, shared by upload-init, rename, and copy so the three cannot drift apart. */
45
+ const logicalPath = z
46
+ .string()
47
+ .min(1)
48
+ .max(MAX_OBJECT_KEY_BYTES)
49
+ .refine(isPrintablePath, { error: "A path may not contain control characters." })
50
+ .describe(
51
+ "The file's logical name, e.g. `invoices/2026/q3.pdf`. Stored, indexed, and listable by prefix; never part of the R2 key, so slashes are naming, not directories.",
52
+ );
53
+
54
+ /**
55
+ * The `:id` path parameter every single-object route carries. Deliberately a bounded generic string
56
+ * and **not** `z.uuid()`: ids are minted with `crypto.randomUUID()`, but this is a shape check, not a
57
+ * lookup. A well-formed id nobody owns must still reach the handler and answer `storage/not_found`
58
+ * (404) — the answer that keeps the route from being an enumeration oracle. A UUID check would turn
59
+ * that into a 400 and hand back a shape the caller could probe against.
60
+ */
61
+ export const ObjectIdParam = z
62
+ .object({
63
+ id: z
64
+ .string()
65
+ .min(1)
66
+ .max(128)
67
+ .describe("The object id from the path. Bounded so an unbounded string never reaches the database."),
68
+ })
69
+ .describe("The path parameter identifying one stored object.");
70
+ export type ObjectIdParam = z.output<typeof ObjectIdParam>;
71
+
72
+ /**
73
+ * The `:token` path parameter the two share routes carry. Bounded only — a minted token is 43
74
+ * base64url characters, but the bound exists to stop an unbounded string reaching the shares table,
75
+ * not to pre-empt the lookup. An unknown-but-well-formed token still answers `storage/not_found`.
76
+ */
77
+ export const ShareTokenParam = z
78
+ .object({
79
+ token: z
80
+ .string()
81
+ .min(1)
82
+ .max(128)
83
+ .describe("The share token from the path — the credential itself, checked against the shares table."),
84
+ })
85
+ .describe("The path parameter identifying one share link.");
86
+ export type ShareTokenParam = z.output<typeof ShareTokenParam>;
87
+
88
+ /**
89
+ * The query string an object read accepts. Only `?download=1` means anything; every other value is
90
+ * simply not a download, which is what the bare string comparison said before and still says. Typed
91
+ * as a bounded string rather than `z.literal("1")` because `?download=0` is a request that works
92
+ * today, and a literal would answer it with a 400.
93
+ */
94
+ export const ObjectReadQuery = z
95
+ .object({
96
+ download: z
97
+ .string()
98
+ .max(16)
99
+ .optional()
100
+ .describe("`1` serves the bytes as an attachment. Anything else, or omitted, serves them inline."),
101
+ })
102
+ .describe("Query parameters for reading an object's bytes, by id or through a share link.");
103
+ export type ObjectReadQuery = z.output<typeof ObjectReadQuery>;
104
+
105
+ export const CreateUploadInput = z
106
+ .object({
107
+ path: logicalPath,
108
+ contentType: z
109
+ .string()
110
+ .min(1)
111
+ .max(255)
112
+ .describe(
113
+ "The file's MIME type, as you intend it. A declaration, not a constraint: a presigned PUT cannot sign a content type, so completion replaces this with the type R2 actually stored.",
114
+ ),
115
+ size: z
116
+ .number()
117
+ .int()
118
+ .nonnegative()
119
+ .describe(
120
+ "The exact byte count. Required: the presigned PUT signs Content-Length, and the pending row reserves these bytes against the owner's quota the moment it is written.",
121
+ ),
122
+ visibility: StorageVisibility.optional().describe(
123
+ "Who may read the file once stored. Omitted takes the configured default, which is private.",
124
+ ),
125
+ })
126
+ .describe("A request to start an upload — what the file is called, what it is, and how big it is.");
127
+ export type CreateUploadInput = z.output<typeof CreateUploadInput>;
128
+
129
+ export const CompleteUploadInput = z
130
+ .object({
131
+ parts: z
132
+ .array(ReportedPart)
133
+ .default([])
134
+ .describe(
135
+ "Every part's number and the ETag R2 answered its PUT with. Empty for a single-PUT upload, which has no parts to assemble.",
136
+ ),
137
+ checksum: z
138
+ .string()
139
+ .regex(/^[0-9a-f]{64}$/)
140
+ .optional()
141
+ .describe(
142
+ "Lowercase hex SHA-256 the client computed over the bytes it sent. Recorded, not verified server-side.",
143
+ ),
144
+ })
145
+ .describe("A request to finalize an upload — the parts to assemble, and optionally what was sent.");
146
+ export type CompleteUploadInput = z.output<typeof CompleteUploadInput>;
147
+
148
+ export const UpdateObjectInput = z
149
+ .object({
150
+ path: logicalPath.optional(),
151
+ visibility: StorageVisibility.optional().describe(
152
+ "Change who may read the file. Takes effect on the next request.",
153
+ ),
154
+ })
155
+ .describe("A rename, a visibility change, or both. At least one field must be present.")
156
+ .check((ctx) => {
157
+ // An empty patch is almost always a client bug — a field name that did not survive serialization.
158
+ // Answering 200 to it would hide that; answering 400 names it.
159
+ if (ctx.value.path === undefined && ctx.value.visibility === undefined) {
160
+ ctx.issues.push({
161
+ code: "custom",
162
+ input: ctx.value,
163
+ path: [],
164
+ message: "Send a path, a visibility, or both — an empty patch changes nothing.",
165
+ });
166
+ }
167
+ });
168
+ export type UpdateObjectInput = z.output<typeof UpdateObjectInput>;
169
+
170
+ export const CopyObjectInput = z
171
+ .object({ path: logicalPath })
172
+ .describe("A server-side copy — the new file's logical path. The bytes never leave R2.");
173
+ export type CopyObjectInput = z.output<typeof CopyObjectInput>;
174
+
175
+ /** One year. A share that outlives the memory of granting it is indistinguishable from a public file. */
176
+ const MAX_SHARE_TTL_SECONDS = 365 * 24 * 60 * 60;
177
+
178
+ export const CreateShareInput = z
179
+ .object({
180
+ expiresInSeconds: z
181
+ .number()
182
+ .int()
183
+ .positive()
184
+ .max(MAX_SHARE_TTL_SECONDS)
185
+ .optional()
186
+ .describe(
187
+ "How long the link works, in seconds. Omitted never expires — revocation is then the only way to end it, which is a deliberate choice rather than a default.",
188
+ ),
189
+ })
190
+ .describe("A request to mint a revocable share link for one file.");
191
+ export type CreateShareInput = z.output<typeof CreateShareInput>;
192
+
193
+ export const ListObjectsQuery = z
194
+ .object({
195
+ prefix: z
196
+ .string()
197
+ .max(MAX_OBJECT_KEY_BYTES)
198
+ .optional()
199
+ .describe("Only files whose logical path starts with this. Omitted lists everything you own."),
200
+ cursor: z
201
+ .string()
202
+ .min(1)
203
+ .optional()
204
+ .describe("The `cursor` a previous page returned. Opaque — pass it back unchanged to advance."),
205
+ limit: z.coerce
206
+ .number()
207
+ .int()
208
+ .min(1)
209
+ .max(100)
210
+ .optional()
211
+ .describe("Files per page. Defaults to 50, capped at 100 so one request cannot scan an entire owner's files."),
212
+ })
213
+ .describe("Query parameters for the owner's file listing.");
214
+ export type ListObjectsQuery = z.output<typeof ListObjectsQuery>;