@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @pithy-sh/storage
|
|
2
|
+
|
|
3
|
+
General file storage in your own R2 bucket, with an owner, a quota, and a link you can take back.
|
|
4
|
+
|
|
5
|
+
Uploads never proxy through your Worker — the client PUTs straight to a presigned URL, and anything past 100 MiB becomes resumable parts. Downloads deliberately do stream through it, because that is the only way a read can be authorized per request.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pithy add storage
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Documentation: [pithy.sh/docs/capabilities/storage](https://pithy.sh/docs/capabilities/storage).** Overview, adding it, using it, and the reference: the object model, provisioning, shares.
|
|
12
|
+
|
|
13
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/storage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/storage"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"!src/**/*.test.*"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
"./src/*": "./src/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:node": "vitest run --project=node",
|
|
27
|
+
"test:workers": "vitest run --project=workers",
|
|
28
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
29
|
+
"clean": "rm -rf dist .turbo",
|
|
30
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
34
|
+
"@hono/zod-validator": "^0.9.0",
|
|
35
|
+
"@pithy-sh/cloudflare": "workspace:*",
|
|
36
|
+
"@pithy-sh/core": "workspace:*",
|
|
37
|
+
"@pithy-sh/secrets": "workspace:*",
|
|
38
|
+
"hono": "^4.13.2",
|
|
39
|
+
"kysely": "^0.29.0",
|
|
40
|
+
"zod": "^4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
44
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
45
|
+
"@types/node": "^22.15.0",
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
47
|
+
"kysely-d1": "^0.4.0",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vitest": "^4.1.0",
|
|
50
|
+
"wrangler": "^4.115.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "storage",
|
|
3
|
+
"package": "@pithy-sh/storage",
|
|
4
|
+
"requiredBindings": [
|
|
5
|
+
{ "type": "d1", "name": "DB" },
|
|
6
|
+
{ "type": "r2", "name": "STORAGE_BUCKET" },
|
|
7
|
+
{
|
|
8
|
+
"type": "workflow",
|
|
9
|
+
"name": "STORAGE_SWEEP",
|
|
10
|
+
"job": "sweep",
|
|
11
|
+
"className": "StorageSweepWorkflow",
|
|
12
|
+
"optional": true
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"peerCapabilities": ["secrets"],
|
|
16
|
+
"optionalCapabilities": ["auth"],
|
|
17
|
+
"migrationNamespace": "storage",
|
|
18
|
+
"secrets": [
|
|
19
|
+
{
|
|
20
|
+
"name": "storage-r2-credentials",
|
|
21
|
+
"origin": {
|
|
22
|
+
"kind": "obtained",
|
|
23
|
+
"issuer": "cloudflare",
|
|
24
|
+
"documentation": "https://developers.cloudflare.com/r2/api/tokens/"
|
|
25
|
+
},
|
|
26
|
+
"rotation": {
|
|
27
|
+
"kind": "manual",
|
|
28
|
+
"issuer": "cloudflare",
|
|
29
|
+
"documentation": "https://developers.cloudflare.com/r2/api/tokens/"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"whenToEnable": "Put your users' files in your own R2, behind one ObjectStore seam any capability can hold. Uploads never pass through your Worker — the client PUTs straight to a presigned URL, so a 40 GiB file costs one request and not one byte of transfer — and anything over 100 MiB is split into resumable parts automatically. Downloads do stream through the Worker, deliberately: that is the only way a read can be authorized per request, honour Range and If-None-Match, and carry a Content-Disposition you chose. Uploaded bytes are treated as untrusted on the way back out: every object response carries nosniff and a locked-down CSP, and an active type (HTML, SVG, anything +xml, script) is served as an attachment whatever was stored — so a user's upload cannot execute on your origin, and this route is not where your own HTML belongs. Keys are server-derived and opaque, so a client can never name an object or guess the one beside it; you supply a logical path, and that path is what gets stored, indexed, and listed. It has per-owner byte quotas that count uploads still in flight, server-side copy, revocable share links (a presigned URL can only expire — a share can be taken back), and a daily Workflow that reconciles orphaned rows and orphaned objects in both directions. It takes no position on what the bytes are — no transcoding, no thumbnails, no AI. That is @pithy-sh/media, which already presigns through this ObjectStore against its own bucket and credential name, and inherits none of these tables or routes for doing so. Files belong to an authenticated owner, so add auth too.",
|
|
34
|
+
"scaffold": [
|
|
35
|
+
"Add a `storage({ ... })` block to pithy.config.ts. Every option has a working default; the one worth setting early is `quota`.",
|
|
36
|
+
"Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
|
|
37
|
+
"Run `pithy migrate` to create pithy_storage_objects and pithy_storage_shares.",
|
|
38
|
+
"Run `pithy storage provision` to create the per-environment R2 buckets, write the credentials secret, and deploy the sweep worker. `pithy add` writes bindings and touches no Cloudflare account; provisioning is the separate, explicit step.",
|
|
39
|
+
"Supply an R2 S3 access-key pair to that command with --r2-access-key-id and --r2-secret-access-key, or set R2_CREDENTIALS in .dev.vars. Cloudflare exposes no API for minting one, so it cannot be created for you — make the pair under R2 -> Manage API tokens.",
|
|
40
|
+
"Add `@pithy-sh/secrets` if it is not already installed. The R2 credentials are read through it, so storage will not compose without it.",
|
|
41
|
+
"Add `@pithy-sh/auth` if it is not already installed. Every owner-scoped route reads the authenticated user from the AuthContext seam; with no auth capability composed those routes deny, which is the right default but not a useful one."
|
|
42
|
+
],
|
|
43
|
+
"configOptions": [
|
|
44
|
+
{
|
|
45
|
+
"key": "basePath",
|
|
46
|
+
"default": "/storage",
|
|
47
|
+
"describe": "Where the object routes mount. Share fetches mount separately at `sharePath`, `/s` by default, kept short because the whole URL gets pasted around."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"key": "multipartThresholdBytes",
|
|
51
|
+
"default": 104857600,
|
|
52
|
+
"describe": "Uploads larger than this are split into resumable parts; smaller ones get a single presigned PUT. 100 MiB. It cannot exceed R2's ~5 GiB single-part cap, because above that multipart is the only way to store the bytes at all."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"key": "partSizeBytes",
|
|
56
|
+
"default": 67108864,
|
|
57
|
+
"describe": "Bytes per part, for every part but the last. 64 MiB. R2 allows at most 10,000 parts, so this fixes the largest object you can store — about 625 GiB. Raise it for larger objects; the cost is that every retry re-sends a bigger part."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"key": "defaultVisibility",
|
|
61
|
+
"default": "private",
|
|
62
|
+
"describe": "Visibility for an upload that does not name one. Private, so a forgotten field can never publish a file."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"key": "pendingTtlSeconds",
|
|
66
|
+
"default": 86400,
|
|
67
|
+
"describe": "How long an unfinished upload holds its quota reservation before the daily sweep reclaims it. One day: long enough for a slow multipart upload, short enough that an abandoned one does not hold a quota forever. Edit the `quota` object by hand to cap per-owner bytes — it is unlimited by default."
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { DatabaseSpecMap } from "@pithy-sh/core/src/data/databases";
|
|
7
|
+
import type { KvNamespaceSpecMap } from "@pithy-sh/core/src/kv/namespaces";
|
|
8
|
+
import { workflowBindings } from "@pithy-sh/core/src/workflow/bindings";
|
|
9
|
+
import type { Migration } from "kysely/migration";
|
|
10
|
+
import { StorageConfig, type StorageConfigInput } from "./config/config";
|
|
11
|
+
import { storageTables } from "./data/tables";
|
|
12
|
+
import { registerStorageRoutes } from "./http/routes";
|
|
13
|
+
import { storage_0001_objects } from "./migrations/0001_objects";
|
|
14
|
+
import { storageSecretsRegistry } from "./secret/registry";
|
|
15
|
+
import { storageExampleSeed } from "./seeds/example";
|
|
16
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
17
|
+
import { storageWorkflows } from "./workflows/specs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where storage's migrations sort in the app database. Unique per database; the registry composes keys
|
|
21
|
+
* like `0800_storage_0001_objects`. Sits after matchmaking (700).
|
|
22
|
+
*/
|
|
23
|
+
export const STORAGE_MIGRATION_ORDER = 800;
|
|
24
|
+
|
|
25
|
+
export type StorageOptions = StorageConfigInput & {
|
|
26
|
+
/** Mount the object routes somewhere other than `/storage`. */
|
|
27
|
+
basePath?: string;
|
|
28
|
+
/** Mount share fetches somewhere other than `/s`. Short by default — the whole URL gets pasted around. */
|
|
29
|
+
sharePath?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The storage capability, with its resolved config attached. The workflow slice is kept literal so a
|
|
34
|
+
* composed project types `c.var.workflows.trigger("storage/sweep", { dryRun: true })` precisely — an
|
|
35
|
+
* unregistered key or a mistyped payload is a compile error, not a 500.
|
|
36
|
+
*/
|
|
37
|
+
export interface StorageCapability
|
|
38
|
+
extends Capability<DatabaseSpecMap, KvNamespaceSpecMap, "storage", typeof storageWorkflows> {
|
|
39
|
+
/** The resolved storage config. */
|
|
40
|
+
storageConfig: StorageConfig;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The storage capability: general file storage on the adopter's own R2 bucket — uploads that never
|
|
45
|
+
* proxy through the Worker, downloads that do, and a D1 record of what exists and who owns it.
|
|
46
|
+
*
|
|
47
|
+
* The bucket is `STORAGE_BUCKET`, deliberately separate from media's `MEDIA_BUCKET`. Buckets are free
|
|
48
|
+
* — cost is bytes and operations — so two cost nothing, and the separation buys independent teardown
|
|
49
|
+
* and independent public-access posture. `visibility: 'public'` is a plausible thing for a file store
|
|
50
|
+
* to offer and never appropriate for media's originals.
|
|
51
|
+
*
|
|
52
|
+
* `dependsOn: ["secrets"]` is real: the R2 credential bundle is read through the aggregated registry
|
|
53
|
+
* at startup, so a project composing storage without `@pithy-sh/secrets` must fail at assembly, not at
|
|
54
|
+
* the first presign. Auth is *not* listed — it is a seam. Ownership scoping reads `c.var.auth.userId`,
|
|
55
|
+
* so without `@pithy-sh/auth` every owner-scoped route denies rather than opens, which is the right
|
|
56
|
+
* failure and needs no dependency edge. It belongs in the manifest's `optionalCapabilities`.
|
|
57
|
+
*/
|
|
58
|
+
export function storage(options: StorageOptions = {}): StorageCapability {
|
|
59
|
+
const { basePath, sharePath, ...configInput } = options;
|
|
60
|
+
// Parse at assembly — an out-of-range part size or a threshold below it fails on deploy, not on the
|
|
61
|
+
// first large upload.
|
|
62
|
+
const resolved = StorageConfig.parse(configInput);
|
|
63
|
+
|
|
64
|
+
const migrations: Record<string, Migration> = { "0001_objects": storage_0001_objects };
|
|
65
|
+
const requiredBindings: BindingSpecInput[] = [
|
|
66
|
+
// The app database — the objects and shares tables live here.
|
|
67
|
+
{ type: "d1", name: "DB" },
|
|
68
|
+
// The bucket the bytes live in. Deliberately separate from media's `MEDIA_BUCKET`.
|
|
69
|
+
{ type: "r2", name: "STORAGE_BUCKET" },
|
|
70
|
+
// The sweep's Workflow binding, derived from the spec rather than written again — one declaration,
|
|
71
|
+
// so a binding rename cannot leave the two disagreeing. Optional: the binding exists only once
|
|
72
|
+
// `pithy storage provision` has deployed the sweep worker, and an unprovisioned project must still
|
|
73
|
+
// serve every upload and download route.
|
|
74
|
+
...workflowBindings(storageWorkflows),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const capability = defineCapability({
|
|
78
|
+
name: "storage",
|
|
79
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
80
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
81
|
+
version: PACKAGE_VERSION,
|
|
82
|
+
dependsOn: ["secrets"],
|
|
83
|
+
secretRegistry: storageSecretsRegistry,
|
|
84
|
+
workflows: storageWorkflows,
|
|
85
|
+
requiredBindings,
|
|
86
|
+
config: StorageConfig,
|
|
87
|
+
databases: {
|
|
88
|
+
app: {
|
|
89
|
+
binding: "DB",
|
|
90
|
+
tables: storageTables(),
|
|
91
|
+
migrationOrder: STORAGE_MIGRATION_ORDER,
|
|
92
|
+
migrations,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
routes: registerStorageRoutes({ config: resolved, basePath, sharePath }),
|
|
96
|
+
seeds: [storageExampleSeed],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return Object.assign(capability, { storageConfig: resolved });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isStorageCapability(capability: Capability): capability is StorageCapability {
|
|
103
|
+
return capability.name === "storage" && "storageConfig" in capability;
|
|
104
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/// <reference types="@cloudflare/vitest-plugin/types" />
|
|
5
|
+
|
|
6
|
+
// Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare config
|
|
7
|
+
// in `vitest.workers.config.ts`: the app `DB` database the `pithy_storage_*` tables live in, and the
|
|
8
|
+
// `STORAGE_BUCKET` R2 bucket objects are written to.
|
|
9
|
+
declare namespace Cloudflare {
|
|
10
|
+
interface Env {
|
|
11
|
+
DB: D1Database;
|
|
12
|
+
STORAGE_BUCKET: R2Bucket;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { StorageVisibility } from "../data/storageObject";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MULTIPART_THRESHOLD_BYTES,
|
|
8
|
+
DEFAULT_PART_SIZE_BYTES,
|
|
9
|
+
MAX_PART_SIZE_BYTES,
|
|
10
|
+
MAX_UPLOAD_PARTS,
|
|
11
|
+
MIN_PART_SIZE_BYTES,
|
|
12
|
+
} from "../object/multipart";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The storage capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field is
|
|
16
|
+
* `.describe()`d: the descriptions feed the self-documenting CLI (CLAUDE.md §Config).
|
|
17
|
+
*
|
|
18
|
+
* Three decisions live here, and each has a real trade behind it.
|
|
19
|
+
*
|
|
20
|
+
* **Quota is unlimited by default, but the field is always present.** A default cap would silently
|
|
21
|
+
* break the first adopter who stores something large; an absent field would mean bolting quotas on
|
|
22
|
+
* later as a breaking change. So the knob exists from day one and starts off.
|
|
23
|
+
*
|
|
24
|
+
* **Multipart threshold 100 MiB, part size 64 MiB.** R2 caps an upload at 10,000 parts, so part size
|
|
25
|
+
* fixes the practical object ceiling: 10,000 × 64 MiB ≈ 625 GiB. Raise `partSizeBytes` to raise the
|
|
26
|
+
* ceiling (R2's own object cap is 4.995 TiB); the cost is that every retry re-sends a larger part.
|
|
27
|
+
*
|
|
28
|
+
* **Default visibility is private.** The safe default is the one where forgetting to set a field
|
|
29
|
+
* cannot leak a file.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The largest single-part upload R2 accepts — 5 GiB, less 5 MiB. Above it, multipart is not optional. */
|
|
33
|
+
const MAX_SINGLE_PART_BYTES = 5 * 1024 * 1024 * 1024 - 5 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
export const StorageQuota = z
|
|
36
|
+
.object({
|
|
37
|
+
bytesPerOwner: z
|
|
38
|
+
.number()
|
|
39
|
+
.int()
|
|
40
|
+
.positive()
|
|
41
|
+
.nullable()
|
|
42
|
+
.default(null)
|
|
43
|
+
.describe(
|
|
44
|
+
"The total bytes one owner may store, counting uploads still in flight. Null is unlimited — the default, because a cap that arrives by surprise breaks an adopter who was already storing more than it.",
|
|
45
|
+
),
|
|
46
|
+
})
|
|
47
|
+
.describe("Per-owner storage limits. Checked when an upload starts, against pending and stored bytes together.");
|
|
48
|
+
export type StorageQuota = z.output<typeof StorageQuota>;
|
|
49
|
+
|
|
50
|
+
export const StorageConfig = z
|
|
51
|
+
.object({
|
|
52
|
+
quota: StorageQuota.prefault({}).describe("Per-owner byte limits. Unlimited unless configured."),
|
|
53
|
+
multipartThresholdBytes: z
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(MIN_PART_SIZE_BYTES)
|
|
57
|
+
.max(MAX_SINGLE_PART_BYTES)
|
|
58
|
+
.default(DEFAULT_MULTIPART_THRESHOLD_BYTES)
|
|
59
|
+
.describe(
|
|
60
|
+
"Uploads larger than this go multipart; smaller ones get one presigned PUT. It cannot exceed R2's ~5 GiB single-part cap, because above that multipart is the only way to store the bytes at all.",
|
|
61
|
+
),
|
|
62
|
+
partSizeBytes: z
|
|
63
|
+
.number()
|
|
64
|
+
.int()
|
|
65
|
+
.min(MIN_PART_SIZE_BYTES)
|
|
66
|
+
.max(MAX_PART_SIZE_BYTES)
|
|
67
|
+
.default(DEFAULT_PART_SIZE_BYTES)
|
|
68
|
+
.describe(
|
|
69
|
+
"Bytes per multipart part, for every part but the last. R2 allows at most 10,000 parts, so this fixes the largest object you can store: 64 MiB gives ~625 GiB. The floor is S3's 5 MiB rule for non-final parts.",
|
|
70
|
+
),
|
|
71
|
+
defaultVisibility: StorageVisibility.default("private").describe(
|
|
72
|
+
"Visibility for an upload that does not name one. Private, so a forgotten field can never publish a file.",
|
|
73
|
+
),
|
|
74
|
+
pendingTtlSeconds: z
|
|
75
|
+
.number()
|
|
76
|
+
.int()
|
|
77
|
+
.positive()
|
|
78
|
+
.default(24 * 60 * 60)
|
|
79
|
+
.describe(
|
|
80
|
+
"How long a `pending` row holds its quota reservation before the orphan sweep reclaims it. Long enough for a slow multipart upload to finish; short enough that an abandoned one does not hold a quota forever.",
|
|
81
|
+
),
|
|
82
|
+
})
|
|
83
|
+
.describe("Configuration for the storage capability — quotas, multipart sizing, and default visibility.")
|
|
84
|
+
.check((ctx) => {
|
|
85
|
+
// A ceiling below the part size would mean the first "multipart" upload is a single short part,
|
|
86
|
+
// which R2 accepts but which makes the threshold meaningless. Say so rather than silently allowing it.
|
|
87
|
+
if (ctx.value.multipartThresholdBytes < ctx.value.partSizeBytes) {
|
|
88
|
+
ctx.issues.push({
|
|
89
|
+
code: "custom",
|
|
90
|
+
input: ctx.value,
|
|
91
|
+
path: ["multipartThresholdBytes"],
|
|
92
|
+
message: `multipartThresholdBytes (${ctx.value.multipartThresholdBytes}) is below partSizeBytes (${ctx.value.partSizeBytes}). Every multipart upload would then be a single part.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
export type StorageConfig = z.output<typeof StorageConfig>;
|
|
97
|
+
export type StorageConfigInput = z.input<typeof StorageConfig>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The largest object this config can store: part size × R2's 10,000-part cap. Surfaced so the CLI and
|
|
101
|
+
* docs can state a real number rather than a rule of thumb.
|
|
102
|
+
*/
|
|
103
|
+
export function maxObjectBytes(config: StorageConfig): number {
|
|
104
|
+
return config.partSizeBytes * MAX_UPLOAD_PARTS;
|
|
105
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One revocable share link — the row in `pithy_storage_shares`.
|
|
9
|
+
*
|
|
10
|
+
* **Why this is a table and not a presigned URL.** A presigned URL cannot be revoked, only expired.
|
|
11
|
+
* Once minted it is valid until its signature lapses, and nothing the owner does can call it back —
|
|
12
|
+
* so "share this file, and let me take it back" is impossible to build on presigning alone. A share
|
|
13
|
+
* is therefore a row: the token is looked up on every fetch, and revoking is a write, effective on
|
|
14
|
+
* the next request.
|
|
15
|
+
*
|
|
16
|
+
* That is also why `revokedAt` is a timestamp rather than a deleted row. A revoked share stays
|
|
17
|
+
* readable as history, and a fetch against it answers `storage/share_revoked` (410 Gone) instead of
|
|
18
|
+
* the `storage/not_found` a deleted row would produce — which tells the holder the link existed and
|
|
19
|
+
* was withdrawn, rather than leaving them guessing.
|
|
20
|
+
*/
|
|
21
|
+
export const StorageShare = z
|
|
22
|
+
.object({
|
|
23
|
+
token: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1)
|
|
26
|
+
.describe("The share token — the primary key, and the whole credential. High-entropy and server-generated."),
|
|
27
|
+
objectId: z.uuid().describe("The object this share grants read access to. References `pithy_storage_objects.id`."),
|
|
28
|
+
expiresAt: SQLiteDate.nullable().describe(
|
|
29
|
+
"When the link stops working. Null never expires — only revocation ends it.",
|
|
30
|
+
),
|
|
31
|
+
revokedAt: SQLiteDate.nullable().describe("When the owner withdrew the link. Null while it is live."),
|
|
32
|
+
createdAt: SQLiteDate.describe("When the link was minted."),
|
|
33
|
+
})
|
|
34
|
+
.describe("One revocable share link — the row in `pithy_storage_shares`.");
|
|
35
|
+
export type StorageShare = z.output<typeof StorageShare>;
|
|
36
|
+
export type StorageShareRow = z.input<typeof StorageShare>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One stored file — the row in `pithy_storage_objects`.
|
|
9
|
+
*
|
|
10
|
+
* Two identifiers, deliberately. `key` is the R2 object key: server-derived, opaque, unique, and
|
|
11
|
+
* never shown to a client. `path` is the adopter's logical name (`invoices/2026/q3.pdf`): indexed and
|
|
12
|
+
* listed from SQL, and never interpolated into a key. Separating them is what removes traversal,
|
|
13
|
+
* collision, and injection surfaces at once, and it keeps the key format an internal detail we can
|
|
14
|
+
* change without breaking an adopter's paths.
|
|
15
|
+
*
|
|
16
|
+
* `id` is a UUID, not an autoincrement integer, because it is externally exposed in every route path
|
|
17
|
+
* (CLAUDE.md §ID strategy) — a sequential id would let anyone enumerate how many files exist and
|
|
18
|
+
* probe for neighbors.
|
|
19
|
+
*
|
|
20
|
+
* `size` is declared by the client at init and *confirmed* against R2 at completion. It is nullable
|
|
21
|
+
* because a caller may not know it up front; a null size reserves no quota, which is why the handler
|
|
22
|
+
* requires it whenever a quota is configured.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const StorageVisibility = z
|
|
26
|
+
.enum(["private", "public"])
|
|
27
|
+
.describe(
|
|
28
|
+
"Who may read an object without a share link: `private` is the owner alone, `public` is anyone with the id. Visibility is authorization, not a bucket setting — the bucket itself stays private.",
|
|
29
|
+
);
|
|
30
|
+
export type StorageVisibility = z.infer<typeof StorageVisibility>;
|
|
31
|
+
|
|
32
|
+
export const StorageObjectStatus = z
|
|
33
|
+
.enum(["pending", "stored", "failed"])
|
|
34
|
+
.describe(
|
|
35
|
+
"Where an upload is: `pending` reserved the row and its quota but has no bytes yet, `stored` has bytes R2 confirmed, `failed` was abandoned. A `pending` row past its TTL is what the orphan sweep reclaims.",
|
|
36
|
+
);
|
|
37
|
+
export type StorageObjectStatus = z.infer<typeof StorageObjectStatus>;
|
|
38
|
+
|
|
39
|
+
export const StorageObject = z
|
|
40
|
+
.object({
|
|
41
|
+
id: z.uuid().describe("The object's public id — a UUID, because it appears in every route path."),
|
|
42
|
+
key: z
|
|
43
|
+
.string()
|
|
44
|
+
.min(1)
|
|
45
|
+
.describe("The opaque R2 object key, server-derived (`obj/<uuid>`). Unique, and never sent to a client."),
|
|
46
|
+
path: z
|
|
47
|
+
.string()
|
|
48
|
+
.min(1)
|
|
49
|
+
.describe("The adopter's logical path (`invoices/2026/q3.pdf`). Indexed and listable; never part of the key."),
|
|
50
|
+
ownerId: z
|
|
51
|
+
.string()
|
|
52
|
+
.nullable()
|
|
53
|
+
.describe("The authenticated user who owns the file, from AuthContext. Null for system-owned objects."),
|
|
54
|
+
contentType: z
|
|
55
|
+
.string()
|
|
56
|
+
.min(1)
|
|
57
|
+
.describe(
|
|
58
|
+
"The MIME type. Declared at init and overwritten at completion with what R2 stored — a presigned PUT cannot sign a content type, so only the completed row is authoritative. An active type is still neutralised on serve.",
|
|
59
|
+
),
|
|
60
|
+
size: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.nonnegative()
|
|
64
|
+
.nullable()
|
|
65
|
+
.describe("Byte count. Declared at init so the pending row reserves quota; confirmed against R2 at completion."),
|
|
66
|
+
visibility: StorageVisibility.describe("Whether anyone with the id may read this object, or only its owner."),
|
|
67
|
+
checksum: z
|
|
68
|
+
.string()
|
|
69
|
+
.nullable()
|
|
70
|
+
.describe("Lowercase hex SHA-256 of the bytes, when one is known. Null until a completion supplies it."),
|
|
71
|
+
status: StorageObjectStatus.describe("The upload's lifecycle state."),
|
|
72
|
+
uploadId: z
|
|
73
|
+
.string()
|
|
74
|
+
.nullable()
|
|
75
|
+
.describe("The multipart upload id while one is in flight. Null for a single-PUT upload and after completion."),
|
|
76
|
+
createdAt: SQLiteDate.describe("When the upload was initiated — the age the orphan sweep measures."),
|
|
77
|
+
updatedAt: SQLiteDate.describe("When the row last changed state."),
|
|
78
|
+
})
|
|
79
|
+
.describe("One stored file — the row in `pithy_storage_objects`.");
|
|
80
|
+
export type StorageObject = z.output<typeof StorageObject>;
|
|
81
|
+
export type StorageObjectRow = z.input<typeof StorageObject>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
import { StorageShare } from "./share";
|
|
9
|
+
import { StorageObject } from "./storageObject";
|
|
10
|
+
|
|
11
|
+
/** The objects table. `CamelCasePlugin` snake-cases it to `pithy_storage_objects`. */
|
|
12
|
+
export const STORAGE_OBJECTS_TABLE = "pithyStorageObjects";
|
|
13
|
+
/** The share-links table. `CamelCasePlugin` snake-cases it to `pithy_storage_shares`. */
|
|
14
|
+
export const STORAGE_SHARES_TABLE = "pithyStorageShares";
|
|
15
|
+
|
|
16
|
+
/** The storage tables map. Both are always present — neither is behind a config flag. */
|
|
17
|
+
export function storageTables(): Record<string, z.ZodObject> {
|
|
18
|
+
return {
|
|
19
|
+
[STORAGE_OBJECTS_TABLE]: StorageObject,
|
|
20
|
+
[STORAGE_SHARES_TABLE]: StorageShare,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The typed Kysely database over the storage tables. */
|
|
25
|
+
export type StorageTables = {
|
|
26
|
+
[STORAGE_OBJECTS_TABLE]: typeof StorageObject;
|
|
27
|
+
[STORAGE_SHARES_TABLE]: typeof StorageShare;
|
|
28
|
+
};
|
|
29
|
+
export type StorageDatabase = Kysely<DatabaseSchema<StorageTables>>;
|
|
30
|
+
|
|
31
|
+
/** Build the storage database from the `DB` binding (CamelCasePlugin installed). */
|
|
32
|
+
export function storageDatabase(d1: D1Database): StorageDatabase {
|
|
33
|
+
return createDatabase(d1, {
|
|
34
|
+
[STORAGE_OBJECTS_TABLE]: StorageObject,
|
|
35
|
+
[STORAGE_SHARES_TABLE]: StorageShare,
|
|
36
|
+
}) as unknown as StorageDatabase;
|
|
37
|
+
}
|