@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,288 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { ReadableStream } from "@cloudflare/workers-types";
5
+ import type { ObjectConditions, ObjectMetadata, ObjectRange } from "../object/store";
6
+
7
+ /**
8
+ * Turning an object read into an HTTP response — the one part of storage where bytes *do* pass
9
+ * through the Worker, and the reason they do.
10
+ *
11
+ * Uploads never proxy through the Worker: the client PUTs straight to a presigned URL, so a 40 GiB
12
+ * file costs the Worker one request and not one byte of transfer (that request presigns 640 part URLs
13
+ * at the default part size — the saving is the bytes, not the signatures). Downloads are the opposite
14
+ * trade, on purpose. A presigned GET
15
+ * cannot be authorized per request (the signature is the authorization, and it is bearer-equivalent
16
+ * once it leaves), cannot be revoked, and cannot carry a `Content-Disposition` the server chose. The
17
+ * moment a file has an owner, a visibility, or a share that can be withdrawn, the read has to be a
18
+ * request the Worker answers. `GET /storage/:id/url` is the escape hatch for callers who would rather
19
+ * have the throughput than the control — it is offered, not the default.
20
+ *
21
+ * Given that, this module owns the parts of HTTP that make a byte-serving route correct rather than
22
+ * merely working: `Range` (206), `If-None-Match` (304), `ETag`, `Content-Length`, `Content-Range`,
23
+ * `Content-Disposition`, and a `Cache-Control` that follows the object's visibility.
24
+ *
25
+ * **Every byte served here is untrusted, and it is served from the adopter's own origin.** That is the
26
+ * threat this module exists to contain. An authenticated user uploads the bytes *and* declares the type;
27
+ * the adopter chooses the mount point, so the response shares an origin with every other Pithy route and
28
+ * with the adopter's own app. There is no separate sandbox domain to fall back on, and we cannot invent
29
+ * one on their behalf. So the server decides how the bytes present, not the uploader:
30
+ *
31
+ * - `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; sandbox` on
32
+ * **every** object response — the id route, `HEAD`, and the share route all build headers here.
33
+ * - An **active type is neutralised**: `text/html`, any `+xml` (which is where `image/svg+xml` lives),
34
+ * and script types are served as `application/octet-stream` with `Content-Disposition: attachment`,
35
+ * whatever was stored and whatever `?download=` said. SVG is the one that makes `attachment`
36
+ * non-negotiable rather than belt-and-braces: it is *meant* to render inline, and a CSP on this
37
+ * response does not travel with it when another origin loads it as an `<img>`.
38
+ * - **`Content-Disposition` is derived, never replayed.** A stored disposition is a string the uploader
39
+ * chose, and honoring it would let them pin `inline` on the very content the rule above forces down.
40
+ *
41
+ * The cost is that this route will not host an adopter's own HTML or JavaScript assets. That is the
42
+ * right trade: it is a store for user files, and Workers Assets or a bucket on its own domain is where
43
+ * first-party markup belongs.
44
+ *
45
+ * **Ranges are resolved against the row's recorded size, not against R2's echo.** A `stored` row
46
+ * knows the object's byte count — it was confirmed against R2 at completion — so `bytes=-500` can be
47
+ * turned into an explicit offset and length before R2 is touched. That makes `Content-Range` exact,
48
+ * makes an unsatisfiable range a 416 that costs no read at all, and removes any dependence on which
49
+ * of R2's four range shapes comes back. A row with no recorded size simply serves the whole object;
50
+ * a wrong `Content-Range` is worse than no range support.
51
+ */
52
+
53
+ /** How a `Range` header resolved against a known object size. */
54
+ export type RangeRequest =
55
+ /** No usable range — serve the whole object, 200. */
56
+ | { kind: "none" }
57
+ /** A single satisfiable byte range — serve 206. */
58
+ | { kind: "bytes"; offset: number; length: number }
59
+ /** A syntactically valid range that starts past the end — 416. */
60
+ | { kind: "unsatisfiable" };
61
+
62
+ /** `bytes=<first>-<last>` / `bytes=<first>-` / `bytes=-<suffix>`, with optional whitespace. */
63
+ const RANGE_PATTERN = /^bytes=(\d*)-(\d*)$/;
64
+
65
+ /**
66
+ * Resolve a `Range` header against the object's recorded size.
67
+ *
68
+ * Multi-range requests (`bytes=0-9,20-29`) are answered with the **whole object**, which RFC 9110
69
+ * explicitly permits: a server may ignore a range it does not wish to satisfy. Building a
70
+ * `multipart/byteranges` body would mean buffering and re-framing every part in the Worker, which is
71
+ * the exact cost this design is trying not to pay, for a feature essentially nothing asks for.
72
+ */
73
+ export function parseRangeHeader(header: string | null | undefined, size: number | null): RangeRequest {
74
+ if (!header || size === null) return { kind: "none" };
75
+ const match = RANGE_PATTERN.exec(header.trim());
76
+ if (!match) return { kind: "none" };
77
+
78
+ const [, rawFirst, rawLast] = match;
79
+ // `bytes=-N`: the last N bytes. N greater than the object is legal and means the whole object.
80
+ if (rawFirst === "") {
81
+ const suffix = Number(rawLast);
82
+ if (rawLast === "" || !Number.isFinite(suffix) || suffix <= 0) return { kind: "none" };
83
+ if (size === 0) return { kind: "unsatisfiable" };
84
+ const length = Math.min(suffix, size);
85
+ return { kind: "bytes", offset: size - length, length };
86
+ }
87
+
88
+ const offset = Number(rawFirst);
89
+ if (!Number.isFinite(offset)) return { kind: "none" };
90
+ // A range that starts at or past the end cannot be satisfied — that is what 416 is for.
91
+ if (offset >= size) return { kind: "unsatisfiable" };
92
+
93
+ // An absent or over-long `last` clamps to the final byte; both are ordinary, not errors.
94
+ const last = rawLast === "" ? size - 1 : Math.min(Number(rawLast), size - 1);
95
+ if (!Number.isFinite(last) || last < offset) return { kind: "none" };
96
+ return { kind: "bytes", offset, length: last - offset + 1 };
97
+ }
98
+
99
+ /** The store-level range for a resolved request, or `undefined` for a whole-object read. */
100
+ export function toObjectRange(request: RangeRequest): ObjectRange | undefined {
101
+ return request.kind === "bytes" ? { offset: request.offset, length: request.length } : undefined;
102
+ }
103
+
104
+ /**
105
+ * The conditional-read preconditions a request's headers imply. Only `If-None-Match` is honored: it
106
+ * is the one that saves a transfer, and the one every cache and browser actually sends. `If-Match`
107
+ * guards writes, which this route does not perform.
108
+ */
109
+ export function parseConditions(ifNoneMatch: string | null | undefined): ObjectConditions | undefined {
110
+ if (!ifNoneMatch) return undefined;
111
+ const value = ifNoneMatch.trim();
112
+ // `*` matches any existing representation, so an existing object is unconditionally not modified.
113
+ // Comparing an etag against the literal "*" would never match, so it is normalized away here.
114
+ if (value === "*") return undefined;
115
+ const etag = unquoteEtag(value.split(",")[0]?.trim() ?? "");
116
+ return etag ? { etagDoesNotMatch: etag } : undefined;
117
+ }
118
+
119
+ /** Strip a weak-validator prefix and the surrounding quotes — R2 stores and compares the bare tag. */
120
+ function unquoteEtag(value: string): string {
121
+ const withoutWeak = value.startsWith("W/") ? value.slice(2) : value;
122
+ return withoutWeak.replace(/^"|"$/g, "");
123
+ }
124
+
125
+ /** What a serve path knows beyond the object's own metadata. */
126
+ export interface ServeOptions {
127
+ /** The object's metadata, as `head` or `get` reported it. */
128
+ metadata: ObjectMetadata;
129
+ /** The logical path — its last segment becomes the download filename. */
130
+ path: string;
131
+ /** Whether anyone may read the object, which decides whether a shared cache may hold it. */
132
+ visibility: "private" | "public";
133
+ /** Serve as a download (`attachment`) rather than inline. */
134
+ download?: boolean;
135
+ }
136
+
137
+ /** A response body plus the range it covers. */
138
+ export interface ServeBody {
139
+ /** The bytes. `null` means a precondition failed — the 304 signal. */
140
+ body: ReadableStream | null;
141
+ /** The range served, or `null` for the whole object. */
142
+ range: { offset: number; length: number } | null;
143
+ }
144
+
145
+ /**
146
+ * Cache lifetimes. A private object is revalidated every time — its authorization can change between
147
+ * two requests, and a cached copy would outlive a visibility change or a revoked share. A public
148
+ * object is immutable in practice (a new upload gets a new id and a new key), so it is cacheable for
149
+ * an hour by shared caches.
150
+ */
151
+ const PRIVATE_CACHE_CONTROL = "private, no-cache, must-revalidate";
152
+ const PUBLIC_CACHE_CONTROL = "public, max-age=3600";
153
+
154
+ /** What an object is served as when its stored type is not one a browser may be trusted with. */
155
+ const NEUTRAL_CONTENT_TYPE = "application/octet-stream";
156
+
157
+ /**
158
+ * The policy every object response carries. `default-src 'none'` means a document made out of these
159
+ * bytes can load nothing and reach nothing; `sandbox` (with no allow-list) drops it into an opaque
160
+ * origin, so even if a browser were talked into rendering it, it is not the adopter's origin any more.
161
+ */
162
+ const OBJECT_CSP = "default-src 'none'; sandbox";
163
+
164
+ /**
165
+ * Types a browser will execute, or render as a document that can execute. Served neutralised.
166
+ *
167
+ * The `+xml` suffix rule is what carries `image/svg+xml`, `application/xhtml+xml` and every structured
168
+ * XML type an adopter has not thought of; the set holds the ones with no suffix to key on. Script types
169
+ * are here because `nosniff` protects a *document* from being re-typed, not a correctly-typed script
170
+ * from being pulled in by a `<script src>` on some other page.
171
+ */
172
+ const ACTIVE_CONTENT_TYPES = new Set([
173
+ "text/html",
174
+ "text/xml",
175
+ "text/xsl",
176
+ "text/javascript",
177
+ "text/ecmascript",
178
+ "application/xml",
179
+ "application/javascript",
180
+ "application/x-javascript",
181
+ "application/ecmascript",
182
+ ]);
183
+
184
+ /** The bare type/subtype, lowercased — `Text/HTML; charset=utf-8` is `text/html` for this decision. */
185
+ function contentTypeEssence(value: string): string {
186
+ return (value.split(";")[0] ?? "").trim().toLowerCase();
187
+ }
188
+
189
+ /** Whether a stored type is one the server refuses to serve as itself. */
190
+ function isActiveContentType(value: string): boolean {
191
+ const essence = contentTypeEssence(value);
192
+ return ACTIVE_CONTENT_TYPES.has(essence) || essence.endsWith("+xml");
193
+ }
194
+
195
+ /** The type to send, and whether that decision also forces a download. */
196
+ function resolveContentType(metadata: ObjectMetadata): { contentType: string; forceAttachment: boolean } {
197
+ const stored = metadata.contentType;
198
+ if (!stored) return { contentType: NEUTRAL_CONTENT_TYPE, forceAttachment: false };
199
+ if (isActiveContentType(stored)) return { contentType: NEUTRAL_CONTENT_TYPE, forceAttachment: true };
200
+ return { contentType: stored, forceAttachment: false };
201
+ }
202
+
203
+ /** The headers every object response carries, regardless of status. */
204
+ function baseHeaders(options: ServeOptions): Headers {
205
+ const { contentType, forceAttachment } = resolveContentType(options.metadata);
206
+ const headers = new Headers();
207
+ headers.set("Content-Type", contentType);
208
+ // The two headers that make an unexpected byte stream harmless. Set here rather than at each route,
209
+ // because `GET /storage/:id`, `HEAD`, and `GET /s/:token` all come through this one builder.
210
+ headers.set("X-Content-Type-Options", "nosniff");
211
+ headers.set("Content-Security-Policy", OBJECT_CSP);
212
+ // Quoted, because an `ETag` header value is a quoted-string by grammar and an unquoted one is
213
+ // silently ignored by some caches — which turns every conditional request into a full transfer.
214
+ headers.set("ETag", `"${options.metadata.etag}"`);
215
+ headers.set("Cache-Control", options.visibility === "public" ? PUBLIC_CACHE_CONTROL : PRIVATE_CACHE_CONTROL);
216
+ // Advertised on every response so a client knows a retry may resume rather than restart.
217
+ headers.set("Accept-Ranges", "bytes");
218
+ headers.set("Content-Disposition", contentDisposition(options, forceAttachment));
219
+ return headers;
220
+ }
221
+
222
+ /**
223
+ * The `Content-Disposition` value, always built from the logical path's last segment. The stored
224
+ * disposition is deliberately ignored: the uploader wrote it, so honoring it would hand them back the
225
+ * `inline` this module just took away — and it would replay an unsanitised string into a response
226
+ * header. The server decides how its own origin presents bytes.
227
+ *
228
+ * Both forms of the filename are emitted — a quote-stripped ASCII `filename` for old clients and an
229
+ * RFC 5987 `filename*` for everything since — because a UTF-8 name in the bare parameter is what
230
+ * makes a browser save `q3.pdf` as `q3.pdf.txt` or worse. Quotes and backslashes are dropped rather
231
+ * than escaped: a filename is not worth a header-injection surface.
232
+ */
233
+ function contentDisposition(options: ServeOptions, forceAttachment: boolean): string {
234
+ const type = forceAttachment || options.download ? "attachment" : "inline";
235
+ const segments = options.path.split("/");
236
+ const raw = segments[segments.length - 1] ?? "download";
237
+ const ascii = raw.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "");
238
+ const name = ascii.length > 0 ? ascii : "download";
239
+ return `${type}; filename="${name}"; filename*=UTF-8''${encodeURIComponent(raw)}`;
240
+ }
241
+
242
+ /**
243
+ * Build the response for an object read: 304 when a precondition failed, 206 when a range was served,
244
+ * 200 otherwise.
245
+ *
246
+ * `Content-Length` is the *served* byte count, so a ranged response reports the range's length while
247
+ * `Content-Range` reports the whole object's size. Getting that pair backwards is the classic way a
248
+ * range implementation appears to work until a client tries to resume.
249
+ */
250
+ export function serveObject(options: ServeOptions & ServeBody): Response {
251
+ const headers = baseHeaders(options);
252
+
253
+ if (options.body === null) {
254
+ // 304 carries validators and cache directives and nothing else — no body, and no Content-Length,
255
+ // because the length of a body that is not being sent is not information. The security headers do
256
+ // stay: a 304 updates the cached response's headers, and dropping them there would let a cache
257
+ // hand back the stored copy without them.
258
+ headers.delete("Content-Type");
259
+ headers.delete("Content-Disposition");
260
+ return new Response(null, { status: 304, headers });
261
+ }
262
+
263
+ if (options.range) {
264
+ const { offset, length } = options.range;
265
+ headers.set("Content-Length", String(length));
266
+ headers.set("Content-Range", `bytes ${offset}-${offset + length - 1}/${options.metadata.size}`);
267
+ return new Response(options.body as unknown as globalThis.ReadableStream, { status: 206, headers });
268
+ }
269
+
270
+ headers.set("Content-Length", String(options.metadata.size));
271
+ return new Response(options.body as unknown as globalThis.ReadableStream, { status: 200, headers });
272
+ }
273
+
274
+ /**
275
+ * The `HEAD` response: exactly the headers a `GET` would carry, with no body. Same builder as the
276
+ * 200 path, so the two cannot describe the same object differently — which is the only thing that
277
+ * makes a `HEAD` worth issuing.
278
+ */
279
+ export function serveMetadata(options: ServeOptions): Response {
280
+ const headers = baseHeaders(options);
281
+ headers.set("Content-Length", String(options.metadata.size));
282
+ return new Response(null, { status: 200, headers });
283
+ }
284
+
285
+ /** The 416 response. `Content-Range: bytes * /size` tells the client what range would have been valid. */
286
+ export function rangeNotSatisfiable(size: number): Response {
287
+ return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
288
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add storage` wires into `pithy.config.ts`.
6
+ *
7
+ * Deliberately narrow: the capability factory and its config, the {@link ObjectStore} seam another
8
+ * capability holds to move bytes (`@pithy-sh/media` is the first), the registry factory that makes a
9
+ * second bucket's credentials resolvable, and the table schemas an adopter reads rows against. Every
10
+ * other module is imported by deep path (`@pithy-sh/storage/src/...`); this is the documented
11
+ * contract, not a barrel over the package.
12
+ */
13
+
14
+ export {
15
+ isStorageCapability,
16
+ STORAGE_MIGRATION_ORDER,
17
+ type StorageCapability,
18
+ type StorageOptions,
19
+ storage,
20
+ } from "./capability";
21
+ export { maxObjectBytes, StorageConfig, type StorageConfigInput, StorageQuota } from "./config/config";
22
+ export { StorageShare } from "./data/share";
23
+ export { StorageObject, StorageObjectStatus, StorageVisibility } from "./data/storageObject";
24
+ export { STORAGE_OBJECTS_TABLE, STORAGE_SHARES_TABLE, type StorageDatabase, storageDatabase } from "./data/tables";
25
+ export { deriveObjectKey, isDerivedObjectKey, OBJECT_KEY_PREFIX } from "./object/key";
26
+ export { collectParts, MultipartPlan, needsMultipart, PartPlan, planMultipart, ReportedPart } from "./object/multipart";
27
+ export {
28
+ ObjectListing,
29
+ ObjectMetadata,
30
+ type ObjectStore,
31
+ type ObjectStoreOptions,
32
+ objectStore,
33
+ type PresignedObjects,
34
+ } from "./object/store";
35
+ export {
36
+ R2StorageCredentials,
37
+ r2CredentialsRegistry,
38
+ STORAGE_R2_SECRET,
39
+ storageSecretsRegistry,
40
+ } from "./secret/registry";
41
+ export { STORAGE_CAPABILITY, StorageSweepParams, storageWorkflows } from "./workflows/specs";
42
+ export { type SweepDeps, type SweepResult, sweepStorage } from "./workflows/sweep";
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type Kysely, sql } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * The storage tables: stored objects, and the revocable share links that point at them.
9
+ *
10
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse,
11
+ * dropping indexes before their tables and the child table before its parent.
12
+ *
13
+ * Three constraints carry weight.
14
+ *
15
+ * `key` is `UNIQUE`. Keys are server-derived UUIDs, so a collision should be impossible — which is
16
+ * exactly why the database should say so. If one ever appears, the insert fails rather than two rows
17
+ * quietly sharing one object's bytes and one deletion orphaning the other.
18
+ *
19
+ * `status` and `visibility` are `CHECK`ed against their enums. The Zod schemas already validate on the
20
+ * way in, but the database is the last line: a hand-run `UPDATE` during an incident cannot leave a row
21
+ * in a state the code has no branch for, and `visibility` in particular is an authorization input.
22
+ *
23
+ * The shares foreign key is `ON DELETE CASCADE`. A share link to a deleted object is not merely stale,
24
+ * it is a dangling authorization; letting the database remove it means no delete path can forget to.
25
+ */
26
+ export const storage_0001_objects: Migration = {
27
+ up: async (db: Kysely<unknown>): Promise<void> => {
28
+ await db.schema
29
+ .createTable("pithyStorageObjects")
30
+ // Text, not autoincrement: the id is in every route path, and a sequential id is an enumeration oracle.
31
+ .addColumn("id", "text", (c) => c.primaryKey())
32
+ .addColumn("key", "text", (c) => c.notNull().unique())
33
+ .addColumn("path", "text", (c) => c.notNull())
34
+ .addColumn("ownerId", "text")
35
+ .addColumn("contentType", "text", (c) => c.notNull())
36
+ .addColumn("size", "integer")
37
+ .addColumn("visibility", "text", (c) => c.notNull().defaultTo("private"))
38
+ .addColumn("checksum", "text")
39
+ .addColumn("status", "text", (c) => c.notNull().defaultTo("pending"))
40
+ .addColumn("uploadId", "text")
41
+ .addColumn("createdAt", "integer", (c) => c.notNull())
42
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
43
+ .addCheckConstraint("pithyStorageObjectsVisibility", sql`visibility IN ('private', 'public')`)
44
+ .addCheckConstraint("pithyStorageObjectsStatus", sql`status IN ('pending', 'stored', 'failed')`)
45
+ .execute();
46
+
47
+ // The owner's file list, by path prefix. Left-prefixed on owner_id so a scoped `LIKE 'prefix%'`
48
+ // walks one owner's slice of the index rather than the whole table.
49
+ await db.schema
50
+ .createIndex("pithyStorageObjectsOwnerPathIdx")
51
+ .on("pithyStorageObjects")
52
+ .columns(["ownerId", "path"])
53
+ .execute();
54
+
55
+ // The quota sum and the orphan sweep both read `WHERE status = ? AND ...`; status leads so both
56
+ // land on the same index, and created_at trails it so the sweep's age scan is a range, not a filter.
57
+ await db.schema
58
+ .createIndex("pithyStorageObjectsStatusIdx")
59
+ .on("pithyStorageObjects")
60
+ .columns(["status", "createdAt"])
61
+ .execute();
62
+
63
+ await db.schema
64
+ .createTable("pithyStorageShares")
65
+ // The token IS the credential, so it is the key — a lookup is a primary-key hit, not a scan.
66
+ .addColumn("token", "text", (c) => c.primaryKey())
67
+ .addColumn("objectId", "text", (c) => c.notNull())
68
+ .addColumn("expiresAt", "integer")
69
+ .addColumn("revokedAt", "integer")
70
+ .addColumn("createdAt", "integer", (c) => c.notNull())
71
+ .addForeignKeyConstraint("pithyStorageSharesObjectFk", ["objectId"], "pithyStorageObjects", ["id"], (fk) =>
72
+ fk.onDelete("cascade"),
73
+ )
74
+ .execute();
75
+
76
+ // Revoking every share on an object, and listing an owner's shares for one file.
77
+ await db.schema.createIndex("pithyStorageSharesObjectIdx").on("pithyStorageShares").columns(["objectId"]).execute();
78
+ },
79
+ down: async (db: Kysely<unknown>): Promise<void> => {
80
+ await db.schema.dropIndex("pithyStorageSharesObjectIdx").execute();
81
+ await db.schema.dropTable("pithyStorageShares").execute();
82
+ await db.schema.dropIndex("pithyStorageObjectsStatusIdx").execute();
83
+ await db.schema.dropIndex("pithyStorageObjectsOwnerPathIdx").execute();
84
+ await db.schema.dropTable("pithyStorageObjects").execute();
85
+ },
86
+ };
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareR2Manager } from "@pithy-sh/cloudflare/src/r2/r2Manager";
5
+ import type { R2StorageCredentials } from "../secret/registry";
6
+ import { type PresignedObjects, UploadedPart } from "./store";
7
+
8
+ /**
9
+ * The SDK-friction boundary: the one file in this package that touches `@pithy-sh/cloudflare`.
10
+ *
11
+ * `store.ts` declares {@link PresignedObjects} as a structural port and never imports a manager, so
12
+ * the seam stays testable with no SDK and no network, and so the S3 client's shape can change without
13
+ * reaching the store. This adapter is the only thing that knows a `CloudflareR2Manager` exists.
14
+ *
15
+ * Every value crossing back is re-validated with the seam's own Zod objects — the manager's schemas
16
+ * are its contract, not ours, and a shape drift should fail here rather than three layers up.
17
+ */
18
+
19
+ /** Adapt a {@link CloudflareR2Manager} to the {@link PresignedObjects} port. */
20
+ export function r2Manager(manager: CloudflareR2Manager): PresignedObjects {
21
+ return {
22
+ presignPut: (key, contentType, contentLength, options) =>
23
+ manager.createUploadUrl(key, contentType, contentLength, { expiresIn: options?.expiresIn }),
24
+ presignGet: (key, options) => manager.createDownloadUrl(key, { expiresIn: options?.expiresIn }),
25
+ createMultipartUpload: (key, contentType) => manager.createMultipartUpload(key, contentType),
26
+ presignUploadPart: (key, uploadId, partNumber, options) =>
27
+ manager.presignUploadPart(key, uploadId, partNumber, {
28
+ expiresIn: options?.expiresIn,
29
+ contentLength: options?.contentLength,
30
+ }),
31
+ completeMultipartUpload: (key, uploadId, parts) => manager.completeMultipartUpload(key, uploadId, parts),
32
+ abortMultipartUpload: (key, uploadId) => manager.abortMultipartUpload(key, uploadId),
33
+ async listParts(key, uploadId) {
34
+ const parts = await manager.listParts(key, uploadId);
35
+ return parts.map((part) => UploadedPart.parse(part));
36
+ },
37
+ copyObject: (sourceKey, destinationKey) => manager.copyObject(sourceKey, destinationKey),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * The default port factory: build an R2 manager from the resolved credential bundle and adapt it.
43
+ * This is what `objectStore` calls when no `presigned` seam is injected.
44
+ */
45
+ export function r2Presigned(credentials: R2StorageCredentials): PresignedObjects {
46
+ return r2Manager(
47
+ new CloudflareR2Manager({
48
+ apiToken: credentials.apiToken,
49
+ accountId: credentials.accountId,
50
+ accessKeyId: credentials.accessKeyId,
51
+ secretAccessKey: credentials.secretAccessKey,
52
+ bucketName: credentials.bucket,
53
+ }),
54
+ );
55
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Key **policy** for the storage capability. It lives here, apart from {@link ObjectStore}, because
6
+ * the store is *mechanism*: it takes an explicit key and moves bytes, and knows nothing about how
7
+ * that key was chosen. Storage derives `obj/<uuid>`; `@pithy-sh/media` passes `media/<type>/<id>`.
8
+ * Neither package knows the other's scheme, which is exactly what lets media import the seam without
9
+ * inheriting storage's opaque-key decision.
10
+ *
11
+ * Why the key is server-derived and opaque. The client supplies a *logical* path
12
+ * (`invoices/2026/q3.pdf`) that is stored, indexed, and listed from D1; it never reaches R2. So a
13
+ * `../` cannot escape a prefix, two clients cannot collide on a name, and no client-controlled text
14
+ * is ever interpolated into a key. It also sidesteps R2's one-write-per-second-per-key limit by
15
+ * construction: every object gets its own key, so no two writes ever contend for one.
16
+ */
17
+
18
+ /** Every key this capability derives starts here, so a bucket sweep can tell its objects from a co-tenant's. */
19
+ export const OBJECT_KEY_PREFIX = "obj/";
20
+
21
+ /** R2's hard limit on an object key, in bytes. A derived key is 40 bytes, so this guards pass-through keys only. */
22
+ export const MAX_OBJECT_KEY_BYTES = 1024;
23
+
24
+ /**
25
+ * A fresh opaque object key. UUIDv4 from the platform CSPRNG — unguessable, so a leaked key is not a
26
+ * directory listing, and unique, so a re-upload never overwrites a live object.
27
+ */
28
+ export function deriveObjectKey(): string {
29
+ return `${OBJECT_KEY_PREFIX}${crypto.randomUUID()}`;
30
+ }
31
+
32
+ /** Whether `key` is one this capability derived. The sweep uses it to leave a co-tenant's objects alone. */
33
+ export function isDerivedObjectKey(key: string): boolean {
34
+ return key.startsWith(OBJECT_KEY_PREFIX);
35
+ }
36
+
37
+ /** Whether `key` fits R2's 1,024-**byte** key limit. Measured in bytes, not characters — a key may be UTF-8. */
38
+ export function isValidObjectKey(key: string): boolean {
39
+ return key.length > 0 && new TextEncoder().encode(key).length <= MAX_OBJECT_KEY_BYTES;
40
+ }