@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +52 -0
- package/pithy.manifest.json +70 -0
- package/src/capability.ts +104 -0
- package/src/cloudflare-test.d.ts +14 -0
- package/src/config/config.ts +105 -0
- package/src/data/share.ts +36 -0
- package/src/data/storageObject.ts +81 -0
- package/src/data/tables.ts +37 -0
- package/src/error/errors.ts +140 -0
- package/src/http/guard.ts +32 -0
- package/src/http/handlers.ts +684 -0
- package/src/http/routes.ts +313 -0
- package/src/http/schemas.ts +214 -0
- package/src/http/serve.ts +288 -0
- package/src/index.ts +42 -0
- package/src/migrations/0001_objects.ts +86 -0
- package/src/object/cloudflare.ts +55 -0
- package/src/object/key.ts +40 -0
- package/src/object/multipart.ts +166 -0
- package/src/object/store.ts +371 -0
- package/src/provision/provisionStorage.ts +192 -0
- package/src/provision/resolveStorageConfig.ts +80 -0
- package/src/quota/quota.ts +241 -0
- package/src/secret/registry.ts +118 -0
- package/src/seeds/example.ts +120 -0
- package/src/test-utils/liveStorage.ts +261 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/retryPolicy.ts +43 -0
- package/src/workflows/specs.ts +85 -0
- package/src/workflows/sweep.ts +226 -0
- package/src/workflows/worker.ts +103 -0
- package/src/workflows/wrangler.jsonc +54 -0
|
@@ -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>;
|