@lenso/storage 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LioRael
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 CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # @lenso/storage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Private object storage with explicit Lenso instance references. Business methods are ordinary async functions. The package root has no filesystem, AWS SDK, database or Web imports.
4
+
5
+ | Import | Runtime / optional dependency |
6
+ | ------------------------- | ------------------------------------------------------------------ |
7
+ | `@lenso/storage` | Object types, errors and plugin factory |
8
+ | `@lenso/storage/local` | Bun, dedicated local directory |
9
+ | `@lenso/storage/s3` | Bun, AWS SDK v3 `client-s3`, `lib-storage`, `s3-request-presigner` |
10
+ | `@lenso/storage/r2` | Workers native R2 binding, no AWS SDK |
11
+ | `@lenso/storage/files` | Optional authorized file records, database supplied by application |
12
+ | `@lenso/storage/sqlite` | Drizzle schema/queries for Bun SQLite or D1 |
13
+ | `@lenso/storage/postgres` | Drizzle schema/queries for Bun SQL PostgreSQL |
14
+ | `@lenso/storage/fetch` | Optional raw Fetch helpers, no listener or implicit routes |
15
+
16
+ Install `@lenso/core` and this package. Install the three AWS SDK peers for `/s3`, or `drizzle-orm@0.45.3` for the database subpaths. Do not import `/local` or `/s3` in a Workers entry. Inspect `storage.capabilities` before selecting signing, conditional or range operations; unsupported operations throw `StorageError` with `code: "unsupported"`.
17
+
18
+ ## Multiple instances and object streams
19
+
20
+ ```ts
21
+ import { defineApp, startApp } from "@lenso/core";
22
+ import { createLocalStoragePlugin } from "@lenso/storage/local";
23
+
24
+ const publicAssets = createLocalStoragePlugin({ id: "publicAssets", root: "./data/assets" });
25
+ const privateFiles = createLocalStoragePlugin({ id: "privateFiles", root: "./data/private" });
26
+ const app = await startApp(defineApp({ plugins: [publicAssets, privateFiles] }));
27
+ try {
28
+ const storage = app.get(privateFiles); // No global default.
29
+ await storage.put({
30
+ key: "contracts/unique-id.pdf",
31
+ body: request.body!,
32
+ contentType: "application/pdf",
33
+ maxBytes: 20 * 1024 * 1024,
34
+ signal: request.signal,
35
+ });
36
+ const download = await storage.get("contracts/unique-id.pdf");
37
+ const response = new Response(download.body, {
38
+ headers: { "content-type": download.metadata.contentType },
39
+ });
40
+ // Return/consume the response before stopping the app.
41
+ await response.body!.pipeTo(destination);
42
+ const metadata = await storage.head("contracts/unique-id.pdf"); // null only for missing
43
+ const page = await storage.list({ prefix: "contracts/", limit: 100 });
44
+ if (page.cursor) await storage.list({ prefix: "contracts/", cursor: page.cursor, limit: 100 });
45
+ await storage.delete("contracts/unique-id.pdf"); // safe retry; provider errors still throw
46
+ } finally {
47
+ await app.stop();
48
+ }
49
+ ```
50
+
51
+ Object services are **trusted internal APIs**, not public endpoints. Knowing a key grants no application permission. Even `publicAssets` stays private: names never enable public ACLs. All uploads create a new key; an existing key causes `conflict`, not overwrite. Keys are not original filenames. Metadata includes provider size, content type, ETag, timestamp, version and custom metadata where available. Listing cursors are backend-specific, not portable or snapshot guarantees.
52
+
53
+ ## S3-compatible and R2
54
+
55
+ ```ts
56
+ import { createS3StoragePlugin } from "@lenso/storage/s3";
57
+ const privateFiles = createS3StoragePlugin({
58
+ id: "privateFiles",
59
+ bucket: "private-files",
60
+ clientConfig: { region: "us-east-1" }, // SDK credential chain; no credentials in source
61
+ });
62
+ // R2 S3 API uses the same adapter, separately from the binding:
63
+ const r2S3 = createS3StoragePlugin({
64
+ id: "r2S3",
65
+ bucket: "files",
66
+ clientConfig: { region: "auto", endpoint: process.env.R2_S3_ENDPOINT },
67
+ multipart: false,
68
+ });
69
+ ```
70
+
71
+ S3-compatible providers must honor conditional create (`If-None-Match: *`). Multipart mode additionally requires conditional `CompleteMultipartUpload`. AWS supports it; R2 does not document that condition on completion. Known R2 endpoints select single-PUT mode, which requires `size`; use `multipart: false` explicitly for other providers without conditional multipart completion. The SDK bounds multipart buffering rather than loading the whole object; `maxUploadBytes` exposes this adapter's limit (5 GiB single PUT; 10,000 × 5 MiB multipart parts). Supplied clients remain application-owned; single-PUT clients need `requestChecksumCalculation: "WHEN_REQUIRED"`. Never give an SDK client a payload-logging logger.
72
+
73
+ ```ts
74
+ import { createR2StoragePlugin } from "@lenso/storage/r2";
75
+ const privateFiles = createR2StoragePlugin({ id: "privateFiles", binding: env.PRIVATE_FILES });
76
+ // Native binding upload needs known size and Workers FixedLengthStream.
77
+ await app.get(privateFiles).put({
78
+ key: crypto.randomUUID(),
79
+ body: request.body!,
80
+ size: trustedExpectedSize,
81
+ maxBytes: serverLimit,
82
+ });
83
+ ```
84
+
85
+ R2 binding has no presigner and no provider request-abort API. Cancellation interrupts the source stream; an already-running platform operation must settle. For R2/local downloads use an application-authorized Fetch endpoint, or separately configure the S3 adapter for R2 presigned URLs. No binding or externally supplied client is closed.
86
+
87
+ `examples/workers/src/storage.ts` also demonstrates both native binding instances and checks against real Workers `R2Bucket` types. It adds no bucket configuration or routes.
88
+
89
+ ## Optional file records and authorization
90
+
91
+ Reuse the existing Notes example, not a second application:
92
+
93
+ ```sh
94
+ bun install
95
+ bun run build
96
+ mkdir -p output
97
+ cd examples/notes
98
+ export NOTES_LOGIN_KEYS="$(bun -e 'console.log(JSON.stringify([{subjectId:"local-demo",key:Array.from(crypto.getRandomValues(new Uint8Array(32)),b=>b.toString(16).padStart(2,"0")).join("")}]))')"
99
+ export NOTES_LOGIN_KEY="$(bun -e 'console.log(JSON.parse(process.env.NOTES_LOGIN_KEYS)[0].key)')"
100
+ SQLITE_PATH=../../output/notes-files.sqlite bun run migrate:sqlite
101
+ SQLITE_PATH=../../output/notes-files.sqlite bun run files:migrate
102
+ SQLITE_PATH=../../output/notes-files.sqlite bun run files:demo
103
+ ```
104
+
105
+ `examples/notes/src/files.ts` binds two local instances, the existing Notes database resource, its Auth instance and an owner/tenant policy. The demo verifies the configured login key before deriving its file access context, streams a private upload, gets a stable `fileId`, downloads it and retries deletion. The login-key source and fixed local tenant are application configuration, not a production identity system. No credentials are written to tracked files.
106
+
107
+ Import `fileSchema` and `createSqliteFileQueries` (or the PostgreSQL equivalents), include the table in your existing database schema, then use `createFilesPlugin({ id, storages, database, queries, authorize })`. `authorize({ access, action, file })` is required for every file operation; omitting it denies access. Owner/tenant fields are application associations, not a user system. Applications authenticate `access` and validate assignments and quota/type policies themselves. The plugin neither creates identities nor assumes future Auth APIs.
108
+
109
+ Migrations belong to this package at `migrations/sqlite/0001_files.sql` and `migrations/postgres/0001_files.sql`. Execute them explicitly using your database migration process; D1 can use `wrangler d1 migrations apply --local`. Starting the plugin never changes the schema. Object-only use requires no database.
110
+
111
+ ## Direct upload and signed download
112
+
113
+ ```ts
114
+ const files = app.get(filePlugin);
115
+ const { file, link } = await files.beginUpload(access, {
116
+ storageId: privateFiles.id,
117
+ filename: "contract.pdf",
118
+ contentType: "application/pdf",
119
+ maxBytes: 20 * 1024 * 1024,
120
+ expiresIn: 300,
121
+ ownerId: access.ownerId,
122
+ tenantId: access.tenantId,
123
+ });
124
+ // Send link only to the authorized client, never a logger.
125
+ await fetch(link.url, { method: link.method, headers: link.headers, body: browserFile });
126
+ const ready = await files.completeUpload(access, file.fileId);
127
+ const download = await files.signDownload(access, ready.fileId, 60);
128
+ await fetch(download.url, { headers: download.headers });
129
+ ```
130
+
131
+ Send **all returned headers**. Signed PUT links bind the key, expiry, content type and create-only condition. `maxBytes` is a completion check, **not a provider-enforced upper bound on uploaded traffic**. Completion reads real object metadata and checks existence, size and content type before publishing a ready record; it can verify an already uploaded object after the PUT credential expires. Reads bind the saved ETag where supported. Temporary credentials can expire earlier than the requested TTL. Browser direct access needs application-managed bucket CORS.
132
+
133
+ URLs are temporary bearer credentials and are not logged by this plugin. Do not put them in analytics, request logs, ordinary RPC/CLI diagnostics or durable file records. A signed link cannot be revoked by removing an application permission. File deletion refuses while its PUT link is live, even after publication: retry after expiry. Failed completion may compensate an invalid object earlier; a still-live PUT can recreate an orphan, but its failed file record cannot become ready. Wait for expiry before reconciling such orphaned keys.
134
+
135
+ ## Raw Fetch, failures and boundaries
136
+
137
+ `createFileUploadHandler({ files, authenticate, input })` and `createFileDownloadHandler({ files, authenticate, fileId })` return raw Fetch-context handlers usable through `createWebPlugin({ fetch: context => handler, ... })`. Applications choose routes, supply authentication and server-controlled upload limits/owner/tenant inputs. Downloads default to attachment, `nosniff` and `private, no-store`; custom inline serving needs application policy. File bytes stay `ReadableStream`s; metadata, direct-upload initiation/completion and business commands can use the existing typed RPC router. Nothing starts an HTTP server or automatically exposes a private route.
138
+
139
+ Object write and database commit are separate operations. File states and conditional transitions keep incomplete uploads unpublished; compensation first claims an unpublished failed state, so an ambiguous DB response cannot delete an already committed ready object. Failed deletions remain retryable, including partial local removal. Cleanup/database/provider failures are retained rather than converted to success. Process termination, ambiguous provider writes or an unavailable DB can leave incomplete work for application reconciliation; there is no implicit background worker or cross-resource transaction.
140
+
141
+ Local roots must be dedicated, application-controlled directories, not upload-selected paths. Traversal and symlinks are rejected, but this is not a sandbox against a hostile process concurrently modifying the filesystem. Do not modify the adapter's on-disk object files manually.
142
+
143
+ No public bucket provisioning, virus scanning, image processing, thumbnails, deduplication, CDN management, cross-cloud copying, resumable client multipart sessions or Console UI is included.
144
+
145
+ Official references: [AWS Upload](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-lib-storage/), [Bun Node compatibility](https://bun.com/docs/runtime/nodejs-compat), [R2 binding API](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/), [R2 S3 compatibility](https://developers.cloudflare.com/r2/api/s3/api/), [R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/), [Workers FixedLengthStream](https://developers.cloudflare.com/workers/runtime-apis/streams/transformstream/).
@@ -0,0 +1,18 @@
1
+ import type { Files, FileUploadInput } from "./files";
2
+ export interface FileFetchContext {
3
+ request: Request;
4
+ }
5
+ type Authenticate<TAccess> = (request: Request) => TAccess | Promise<TAccess>;
6
+ /** Structurally usable as a Web raw Fetch handler; authentication remains app-owned. */
7
+ export declare function createFileDownloadHandler<TAccess>(options: {
8
+ files: Files<TAccess>;
9
+ authenticate: Authenticate<TAccess>;
10
+ fileId(request: Request): string | null | Promise<string | null>;
11
+ }): (context: FileFetchContext) => Promise<Response | undefined>;
12
+ export declare function createFileUploadHandler<TAccess>(options: {
13
+ files: Files<TAccess>;
14
+ authenticate: Authenticate<TAccess>;
15
+ /** The app chooses storage, ownership, filename and limits, never object keys. */
16
+ input(request: Request, access: TAccess): FileUploadInput | null | Promise<FileUploadInput | null>;
17
+ }): (context: FileFetchContext) => Promise<Response | undefined>;
18
+ export {};
package/dist/fetch.js ADDED
@@ -0,0 +1,98 @@
1
+ import {
2
+ StorageError2
3
+ } from "./index-0fz1t930.js";
4
+
5
+ // src/fetch.ts
6
+ var privateDownloadHeaders = {
7
+ "cache-control": "private, no-store",
8
+ "content-disposition": "attachment",
9
+ "x-content-type-options": "nosniff"
10
+ };
11
+ async function respond(operation) {
12
+ try {
13
+ return await operation();
14
+ } catch (error) {
15
+ if (!(error instanceof StorageError2))
16
+ throw error;
17
+ const status = {
18
+ "invalid-key": 400,
19
+ "invalid-input": 400,
20
+ "not-found": 404,
21
+ forbidden: 403,
22
+ unsupported: 501,
23
+ conflict: 409,
24
+ "too-large": 413,
25
+ aborted: 499,
26
+ provider: 502
27
+ }[error.code];
28
+ return Response.json({ error: error.code }, {
29
+ status,
30
+ headers: { "cache-control": "private, no-store" }
31
+ });
32
+ }
33
+ }
34
+ function createFileDownloadHandler(options) {
35
+ return async ({ request }) => {
36
+ if (!["GET", "HEAD", "DELETE"].includes(request.method))
37
+ return;
38
+ const fileId = await options.fileId(request);
39
+ if (fileId === null)
40
+ return;
41
+ return respond(async () => {
42
+ const access = await options.authenticate(request);
43
+ if (request.method === "DELETE") {
44
+ await options.files.delete(access, fileId);
45
+ return new Response(null, { status: 204 });
46
+ }
47
+ if (request.method === "HEAD") {
48
+ const file = await options.files.metadata(access, fileId);
49
+ if (file.state !== "ready")
50
+ throw new StorageError2("conflict", "File is not ready");
51
+ return new Response(null, {
52
+ headers: {
53
+ ...privateDownloadHeaders,
54
+ "content-type": file.contentType,
55
+ "content-length": String(file.size),
56
+ ...file.etag ? { etag: file.etag } : {}
57
+ }
58
+ });
59
+ }
60
+ const object = await options.files.read(access, fileId, { signal: request.signal });
61
+ return new Response(object.body, {
62
+ headers: {
63
+ ...privateDownloadHeaders,
64
+ "content-type": object.metadata.contentType,
65
+ "content-length": String(object.metadata.size),
66
+ ...object.metadata.etag ? { etag: object.metadata.etag } : {}
67
+ }
68
+ });
69
+ });
70
+ };
71
+ }
72
+ function createFileUploadHandler(options) {
73
+ return async ({ request }) => {
74
+ if (request.method !== "PUT")
75
+ return;
76
+ return respond(async () => {
77
+ const access = await options.authenticate(request);
78
+ const input = await options.input(request, access);
79
+ if (!input)
80
+ return new Response(null, { status: 404 });
81
+ if (!request.body)
82
+ throw new StorageError2("invalid-input", "Upload body required");
83
+ const file = await options.files.upload(access, {
84
+ ...input,
85
+ body: request.body,
86
+ signal: request.signal
87
+ });
88
+ return Response.json(file, {
89
+ status: 201,
90
+ headers: { "cache-control": "private, no-store" }
91
+ });
92
+ });
93
+ };
94
+ }
95
+ export {
96
+ createFileDownloadHandler,
97
+ createFileUploadHandler
98
+ };
@@ -0,0 +1,68 @@
1
+ import { type Plugin } from "@lenso/core/plugin";
2
+ import { type ObjectStorage, type ObjectDownload, type SignedObjectLink } from "./index";
3
+ export type FileState = "pending" | "uploading" | "ready" | "failed" | "deleting" | "deleted";
4
+ export type FileAction = "upload" | "completeUpload" | "metadata" | "read" | "delete" | "signDownload";
5
+ export interface FileRecord {
6
+ fileId: string;
7
+ storageId: string;
8
+ objectKey: string;
9
+ filename: string;
10
+ contentType: string;
11
+ ownerId: string | null;
12
+ tenantId: string | null;
13
+ state: FileState;
14
+ revision: number;
15
+ size: number | null;
16
+ expectedSize: number | null;
17
+ maxBytes: number | null;
18
+ etag: string | null;
19
+ uploadExpiresAt: number | null;
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ }
23
+ export interface FileQueries {
24
+ insert(file: FileRecord): Promise<void>;
25
+ get(fileId: string): Promise<FileRecord | null>;
26
+ /** Atomically replace only the matching revision; return false on contention. */
27
+ transition(fileId: string, revision: number, next: FileRecord): Promise<boolean>;
28
+ }
29
+ export interface FileUploadInput {
30
+ storageId: string;
31
+ filename: string;
32
+ contentType: string;
33
+ size?: number;
34
+ maxBytes?: number;
35
+ ownerId?: string;
36
+ tenantId?: string;
37
+ signal?: AbortSignal;
38
+ }
39
+ export interface Files<TAccess> {
40
+ upload(access: TAccess, input: FileUploadInput & {
41
+ body: ReadableStream<Uint8Array>;
42
+ }): Promise<FileRecord>;
43
+ beginUpload(access: TAccess, input: FileUploadInput & {
44
+ maxBytes: number;
45
+ expiresIn: number;
46
+ }): Promise<{
47
+ file: FileRecord;
48
+ link: SignedObjectLink;
49
+ }>;
50
+ completeUpload(access: TAccess, fileId: string): Promise<FileRecord>;
51
+ metadata(access: TAccess, fileId: string): Promise<FileRecord>;
52
+ read(access: TAccess, fileId: string, options?: {
53
+ signal?: AbortSignal;
54
+ }): Promise<ObjectDownload>;
55
+ delete(access: TAccess, fileId: string): Promise<FileRecord>;
56
+ signDownload(access: TAccess, fileId: string, expiresIn: number): Promise<SignedObjectLink>;
57
+ }
58
+ export declare function createFilesPlugin<TDb, TAccess>(options: {
59
+ id: string;
60
+ storages: readonly Plugin<ObjectStorage>[];
61
+ database: Plugin<TDb>;
62
+ queries(db: TDb): FileQueries;
63
+ authorize?: (input: {
64
+ access: TAccess;
65
+ action: FileAction;
66
+ file: Readonly<FileRecord>;
67
+ }) => boolean | Promise<boolean>;
68
+ }): Plugin<Files<TAccess>>;
package/dist/files.js ADDED
@@ -0,0 +1,237 @@
1
+ import {
2
+ StorageError2,
3
+ validateContentType2,
4
+ validateExpiry2
5
+ } from "./index-0fz1t930.js";
6
+ import {
7
+ validateUpload,
8
+ checkedUpload
9
+ } from "./index-fa6z0g4w.js";
10
+
11
+ // src/files.ts
12
+ import { definePlugin } from "@lenso/core/plugin";
13
+ function assertFileReady(file) {
14
+ if (file.state !== "ready")
15
+ throw new StorageError2("conflict", "File is not ready");
16
+ }
17
+ function verifyUploadedMetadata(file, object, direct) {
18
+ if (!object)
19
+ throw new StorageError2("not-found", "Uploaded object is missing");
20
+ if (object.key !== file.objectKey || object.contentType !== file.contentType || !Number.isSafeInteger(object.size) || object.size < 0 || file.expectedSize !== null && object.size !== file.expectedSize) {
21
+ throw new StorageError2("invalid-input", "Uploaded metadata does not match");
22
+ }
23
+ if (file.maxBytes !== null && object.size > file.maxBytes) {
24
+ throw new StorageError2("too-large", "Uploaded object exceeds size limit");
25
+ }
26
+ if (direct && !object.etag)
27
+ throw new StorageError2("unsupported", "Direct uploads require an object etag");
28
+ return object;
29
+ }
30
+ function createFileDraft(input) {
31
+ validateUpload({ ...input, key: "validation", body: new ReadableStream });
32
+ validateContentType2(input.contentType);
33
+ if (!input.filename)
34
+ throw new StorageError2("invalid-input", "Filename is required");
35
+ const now = Date.now();
36
+ return {
37
+ fileId: crypto.randomUUID(),
38
+ objectKey: `files/${crypto.randomUUID()}`,
39
+ storageId: input.storageId,
40
+ filename: input.filename,
41
+ contentType: input.contentType,
42
+ ownerId: input.ownerId ?? null,
43
+ tenantId: input.tenantId ?? null,
44
+ state: "pending",
45
+ revision: 0,
46
+ size: null,
47
+ etag: null,
48
+ expectedSize: input.size ?? null,
49
+ maxBytes: input.maxBytes ?? null,
50
+ uploadExpiresAt: null,
51
+ createdAt: now,
52
+ updatedAt: now
53
+ };
54
+ }
55
+ function createFilesPlugin(options) {
56
+ return definePlugin({
57
+ id: options.id,
58
+ requires: [options.database, ...options.storages],
59
+ setup(context) {
60
+ const queries = options.queries(context.get(options.database));
61
+ const storages = new Map(options.storages.map((ref) => [ref.id, context.get(ref)]));
62
+ function storage(file) {
63
+ const result = storages.get(file.storageId);
64
+ if (!result)
65
+ throw new StorageError2("invalid-input", "Unknown storage");
66
+ return result;
67
+ }
68
+ async function authorize(access, action, file) {
69
+ if (!options.authorize || !await options.authorize({ access, action, file: Object.freeze({ ...file }) })) {
70
+ throw new StorageError2("forbidden", "File operation denied");
71
+ }
72
+ }
73
+ async function load(access, action, fileId) {
74
+ const file = await queries.get(fileId);
75
+ if (!file)
76
+ throw new StorageError2("not-found", "File not found");
77
+ await authorize(access, action, file);
78
+ return file;
79
+ }
80
+ async function transition(file, patch) {
81
+ const next = { ...file, ...patch, revision: file.revision + 1, updatedAt: Date.now() };
82
+ if (!await queries.transition(file.fileId, file.revision, next)) {
83
+ throw new StorageError2("conflict", "File state changed concurrently");
84
+ }
85
+ return next;
86
+ }
87
+ async function fail(file, error, cleanup) {
88
+ const errors = [error];
89
+ let claimed = false;
90
+ try {
91
+ await transition(file, { state: "failed" });
92
+ claimed = true;
93
+ } catch (cause) {
94
+ errors.push(cause);
95
+ try {
96
+ const current = await queries.get(file.fileId);
97
+ claimed = current?.state === "failed" && current.revision === file.revision + 1 && current.storageId === file.storageId && current.objectKey === file.objectKey;
98
+ } catch (lookupError) {
99
+ errors.push(lookupError);
100
+ }
101
+ }
102
+ if (cleanup && claimed) {
103
+ try {
104
+ await storage(file).delete(file.objectKey);
105
+ } catch (cause) {
106
+ errors.push(cause);
107
+ }
108
+ }
109
+ if (errors.length > 1)
110
+ throw new AggregateError(errors, "Upload and recovery failed");
111
+ throw error;
112
+ }
113
+ return {
114
+ async upload(access, input) {
115
+ let file = createFileDraft(input);
116
+ const objects = storage(file);
117
+ await authorize(access, "upload", file);
118
+ await queries.insert(file);
119
+ file = await transition(file, { state: "uploading" });
120
+ let checked;
121
+ let owned = false;
122
+ try {
123
+ checked = checkedUpload({ ...input, key: file.objectKey });
124
+ owned = true;
125
+ const object = await objects.put({ ...input, key: file.objectKey, body: checked.body });
126
+ verifyUploadedMetadata(file, object, false);
127
+ if (object.size !== checked.size())
128
+ throw new StorageError2("invalid-input", "Adapter did not consume the upload");
129
+ await checked.cancel();
130
+ return await transition(file, {
131
+ state: "ready",
132
+ size: object.size,
133
+ etag: object.etag ?? null
134
+ });
135
+ } catch (error) {
136
+ if (error instanceof StorageError2 && (error.code === "conflict" || error.code === "forbidden")) {
137
+ owned = false;
138
+ }
139
+ let failure = error;
140
+ try {
141
+ await checked?.cancel(error);
142
+ } catch (cause) {
143
+ failure = new AggregateError([error, cause], "Upload and source cleanup failed");
144
+ }
145
+ return await fail(file, failure, owned);
146
+ }
147
+ },
148
+ async beginUpload(access, input) {
149
+ validateExpiry2(input.expiresIn);
150
+ if (input.maxBytes === undefined)
151
+ throw new StorageError2("invalid-input", "Direct upload size limit is required");
152
+ let file = createFileDraft(input);
153
+ const objects = storage(file);
154
+ await authorize(access, "upload", file);
155
+ if (!objects.capabilities.signedUpload || !objects.capabilities.conditionalRead) {
156
+ throw new StorageError2("unsupported", "Safe direct uploads require signing and conditional reads");
157
+ }
158
+ await queries.insert(file);
159
+ try {
160
+ const link = await objects.signUpload({
161
+ key: file.objectKey,
162
+ expiresIn: input.expiresIn,
163
+ contentType: file.contentType,
164
+ maxBytes: input.maxBytes
165
+ });
166
+ if (link.method !== "PUT" || !link.conditions.createOnly || !Number.isFinite(link.expiresAt.getTime()) || link.expiresAt.getTime() <= Date.now() || link.expiresAt.getTime() > Date.now() + input.expiresIn * 1000) {
167
+ throw new StorageError2("unsupported", "Adapter did not issue a bounded create-only upload");
168
+ }
169
+ file = await transition(file, { uploadExpiresAt: link.expiresAt.getTime() });
170
+ return { file, link };
171
+ } catch (error) {
172
+ return await fail(file, error, false);
173
+ }
174
+ },
175
+ async completeUpload(access, fileId) {
176
+ let file = await load(access, "completeUpload", fileId);
177
+ if (file.state !== "pending" || file.uploadExpiresAt === null) {
178
+ throw new StorageError2("conflict", "File is not awaiting direct completion");
179
+ }
180
+ file = await transition(file, { state: "uploading" });
181
+ try {
182
+ const object = verifyUploadedMetadata(file, await storage(file).head(file.objectKey), true);
183
+ return await transition(file, {
184
+ state: "ready",
185
+ size: object.size,
186
+ etag: object.etag
187
+ });
188
+ } catch (error) {
189
+ return await fail(file, error, true);
190
+ }
191
+ },
192
+ async metadata(access, fileId) {
193
+ return load(access, "metadata", fileId);
194
+ },
195
+ async read(access, fileId, readOptions) {
196
+ const file = await load(access, "read", fileId);
197
+ assertFileReady(file);
198
+ const objects = storage(file);
199
+ return objects.get(file.objectKey, {
200
+ ...readOptions,
201
+ ifMatch: objects.capabilities.conditionalRead ? file.etag ?? undefined : undefined
202
+ });
203
+ },
204
+ async delete(access, fileId) {
205
+ let file = await load(access, "delete", fileId);
206
+ if (file.state === "deleted")
207
+ return file;
208
+ if (file.state === "uploading")
209
+ throw new StorageError2("conflict", "Upload is in progress");
210
+ if (file.uploadExpiresAt !== null && Date.now() < file.uploadExpiresAt) {
211
+ throw new StorageError2("conflict", "Wait for the upload credential to expire before deleting");
212
+ }
213
+ file = await transition(file, { state: "deleting" });
214
+ await storage(file).delete(file.objectKey);
215
+ return transition(file, { state: "deleted" });
216
+ },
217
+ async signDownload(access, fileId, expiresIn) {
218
+ validateExpiry2(expiresIn);
219
+ const file = await load(access, "signDownload", fileId);
220
+ assertFileReady(file);
221
+ const link = await storage(file).signDownload({
222
+ key: file.objectKey,
223
+ expiresIn,
224
+ ifMatch: file.etag ?? undefined
225
+ });
226
+ if (file.etag && link.conditions.ifMatch !== file.etag) {
227
+ throw new StorageError2("unsupported", "Signed download cannot pin the published object");
228
+ }
229
+ return link;
230
+ }
231
+ };
232
+ }
233
+ });
234
+ }
235
+ export {
236
+ createFilesPlugin
237
+ };
@@ -0,0 +1,64 @@
1
+ // src/index.ts
2
+ import { definePlugin } from "@lenso/core/plugin";
3
+
4
+ class StorageError2 extends Error {
5
+ code;
6
+ constructor(code, message, options) {
7
+ super(message, options);
8
+ this.code = code;
9
+ this.name = "StorageError";
10
+ }
11
+ }
12
+ function createStoragePlugin2(options) {
13
+ return definePlugin({
14
+ id: options.id,
15
+ requires: options.requires,
16
+ async setup(context) {
17
+ return { ...await options.setup(context), id: options.id };
18
+ }
19
+ });
20
+ }
21
+ function validateKey2(key) {
22
+ let unsafeCharacter = false;
23
+ for (const character of key) {
24
+ const code = character.charCodeAt(0);
25
+ if (character === "\\" || code < 32 || code === 127)
26
+ unsafeCharacter = true;
27
+ }
28
+ if (!key || new TextEncoder().encode(key).length > 1024 || unsafeCharacter || key.split("/").some((part) => !part || part === "." || part === "..") || key.startsWith("/") || /^[a-zA-Z]:/.test(key))
29
+ throw new StorageError2("invalid-key", "Object key must be a relative, nonempty path without traversal");
30
+ }
31
+ function validateContentType2(contentType) {
32
+ if (!contentType || contentType.length > 1024 || [...contentType].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) {
33
+ throw new StorageError2("invalid-input", "Content type must be a nonempty header value without control characters");
34
+ }
35
+ }
36
+ function validateList2(input) {
37
+ if (input.prefix)
38
+ validateKey2(input.prefix.endsWith("/") ? input.prefix.slice(0, -1) : input.prefix);
39
+ const limit = input.limit ?? 100;
40
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
41
+ throw new StorageError2("invalid-input", "List limit must be between 1 and 1000");
42
+ }
43
+ return limit;
44
+ }
45
+ function validateRange2(options, supported) {
46
+ if (!options.range)
47
+ return;
48
+ if (!supported)
49
+ throw new StorageError2("unsupported", "Range reads are not supported");
50
+ const { offset, length } = options.range;
51
+ if (!Number.isSafeInteger(offset) || offset < 0 || length !== undefined && (!Number.isSafeInteger(length) || length <= 0)) {
52
+ throw new StorageError2("invalid-input", "Range needs a nonnegative offset and positive length");
53
+ }
54
+ }
55
+ function validateExpiry2(expiresIn) {
56
+ if (!Number.isSafeInteger(expiresIn) || expiresIn < 1 || expiresIn > 604800) {
57
+ throw new StorageError2("invalid-input", "Link expiry must be 1 to 604800 seconds; credentials may expire sooner");
58
+ }
59
+ }
60
+ function unsupportedSigning2() {
61
+ throw new StorageError2("unsupported", "This adapter cannot issue signed URLs; configure an authorized application Fetch endpoint");
62
+ }
63
+
64
+ export { StorageError2, createStoragePlugin2, validateKey2, validateContentType2, validateList2, validateRange2, validateExpiry2, unsupportedSigning2 };