File storage for S3-compatible buckets (R2, S3, MinIO) and local disk, with signed direct uploads
Install
genpm add @core/storageWhat you get
- Source in src/lib/storage/, 11 files. (22.7 kB)
- AI rules in src/lib/storage/AGENTS.md, plus IDE rule files.
- Env vars added to .env.example: STORAGE_DRIVER, STORAGE_BUCKET, STORAGE_ENDPOINT, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY, STORAGE_PUBLIC_URL, STORAGE_SECRET, STORAGE_LOCAL_DIR.
README
This package has no README.
This is exactly what your AI reads when it works in src/lib/storage. Nothing else is added to its context.
@core/storage — rules for AI agents
Purpose
One StorageProvider interface to store and serve files: S3-compatible buckets (Cloudflare R2, AWS S3, MinIO, B2)
signed with SigV4 (aws4fetch, works on Node and Workers), plus local (disk) and memory drivers for development
and tests. Signed direct uploads from the browser with the exact size and content type signed. No media library,
no image processing (that is @core/media). No tables.
Map
index.ts— public API:getStorage(),setStorage(),generateKey(),assertSafeKey(),s3Storage(), types.s3.ts— S3 driver.memory.ts— local/memory drivers and signed tokens.local-routes.ts— handlers for them.adapters/hono.ts,adapters/next.ts— upload/download route, only needed withlocal/memory.
Integration
- Env for S3/R2 (default driver):
STORAGE_ENDPOINT(R2:https://<account>.r2.cloudflarestorage.com),STORAGE_BUCKET,STORAGE_ACCESS_KEY_ID,STORAGE_SECRET_ACCESS_KEY, optionalSTORAGE_REGION(defaultauto) andSTORAGE_PUBLIC_URL(CDN/custom domain serving thepublic/prefix). Local dev:STORAGE_DRIVER=local,STORAGE_SECRET(≥ 32 chars),STORAGE_PUBLIC_URL=http://localhost:3000(files go toSTORAGE_LOCAL_DIR, default.storage/: add it to.gitignore), and mount the route: Honoapp.route('/api/storage', localStorageRoutes()); Next.jsapp/api/storage/[[...key]]/route.tswithexport { localStoragePut as PUT, localStorageGet as GET } from '@/lib/storage/adapters/next'. - Bucket CORS (S3/R2): allow
PUTfrom your origin with headercontent-type. - Direct upload: server creates
const key = generateKey('public/media', file.name)andawait getStorage().presignUpload(key, { contentType, size, maxBytes: 10_000_000 }); the browser doesfetch(url, { method: 'PUT', headers, body: file }). - Verify: upload a small file, then
getStorage().head(key)returns its size.
Conventions
- Keys are generated by the server (
generateKey); never use the user's filename or path as the key. - Everything is private unless its key starts with
public/; serve private files withpresignDownloadafter an authorization check. - Store the key in your table, not the URL (URLs change with the CDN or driver).
- A presigned upload URL can be reused until it expires. If the server validates or transforms the file after upload,
presign a private key (e.g.
uploads/pending/…) and have the server copy the validated bytes to the finalpublic/key.
Don't
- Don't make the whole bucket public or proxy private files without checking permissions.
- Don't trust the client's content type for security decisions; @core/media sniffs the real type after upload.
- Don't log storage credentials or presigned URLs.
# @core/storage — rules for AI agents
## Purpose
One `StorageProvider` interface to store and serve files: S3-compatible buckets (Cloudflare R2, AWS S3, MinIO, B2)
signed with SigV4 (`aws4fetch`, works on Node and Workers), plus `local` (disk) and `memory` drivers for development
and tests. Signed direct uploads from the browser with the exact size and content type signed. No media library,
no image processing (that is @core/media). No tables.
## Map
- `index.ts` — public API: `getStorage()`, `setStorage()`, `generateKey()`, `assertSafeKey()`, `s3Storage()`, types.
- `s3.ts` — S3 driver. `memory.ts` — local/memory drivers and signed tokens. `local-routes.ts` — handlers for them.
- `adapters/hono.ts`, `adapters/next.ts` — upload/download route, only needed with `local`/`memory`.
## Integration
1. Env for S3/R2 (default driver): `STORAGE_ENDPOINT` (R2: `https://<account>.r2.cloudflarestorage.com`),
`STORAGE_BUCKET`, `STORAGE_ACCESS_KEY_ID`, `STORAGE_SECRET_ACCESS_KEY`, optional `STORAGE_REGION` (default `auto`)
and `STORAGE_PUBLIC_URL` (CDN/custom domain serving the `public/` prefix).
Local dev: `STORAGE_DRIVER=local`, `STORAGE_SECRET` (≥ 32 chars), `STORAGE_PUBLIC_URL=http://localhost:3000`
(files go to `STORAGE_LOCAL_DIR`, default `.storage/`: add it to `.gitignore`), and
mount the route: Hono `app.route('/api/storage', localStorageRoutes())`; Next.js
`app/api/storage/[[...key]]/route.ts` with `export { localStoragePut as PUT, localStorageGet as GET } from '@/lib/storage/adapters/next'`.
2. Bucket CORS (S3/R2): allow `PUT` from your origin with header `content-type`.
3. Direct upload: server creates `const key = generateKey('public/media', file.name)` and
`await getStorage().presignUpload(key, { contentType, size, maxBytes: 10_000_000 })`; the browser does
`fetch(url, { method: 'PUT', headers, body: file })`.
4. Verify: upload a small file, then `getStorage().head(key)` returns its size.
## Conventions
- Keys are generated by the server (`generateKey`); never use the user's filename or path as the key.
- Everything is private unless its key starts with `public/`; serve private files with `presignDownload` after an authorization check.
- Store the key in your table, not the URL (URLs change with the CDN or driver).
- A presigned upload URL can be reused until it expires. If the server validates or transforms the file after upload,
presign a private key (e.g. `uploads/pending/…`) and have the server copy the validated bytes to the final `public/` key.
## Don't
- Don't make the whole bucket public or proxy private files without checking permissions.
- Don't trust the client's content type for security decisions; @core/media sniffs the real type after upload.
- Don't log storage credentials or presigned URLs.
The exact tree that will be injected, after .genpmignore. Pinned to
// Driver S3 compatible (Cloudflare R2, AWS S3, MinIO, Backblaze B2) con firma SigV4 de aws4fetch. Funciona en Node y Workers.
import { AwsClient, AwsV4Signer } from 'aws4fetch';
import { assertSafeKey, isPublicKey, StorageError } from './keys.ts';
import { type PresignUploadOptions, type PutBody, type PutOptions, type StorageProvider, toBytes } from './provider.ts';
export type S3Config = {
endpoint: string;
bucket: string;
region?: string;
accessKeyId: string;
secretAccessKey: string;
/** Base pública (CDN o dominio del bucket) para claves `public/`. */
publicUrl?: string;
/** Inyectable para tests. */
fetch?: typeof fetch;
};
const encodeKey = (key: string) => key.split('/').map(encodeURIComponent).join('/');
export function s3Storage(cfg: S3Config): StorageProvider {
const region = cfg.region ?? 'auto';
const client = new AwsClient({ accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey, service: 's3', region });
const doFetch = cfg.fetch ?? fetch;
const objectUrl = (key: string) => `${cfg.endpoint.replace(/\/+$/, '')}/${cfg.bucket}/${encodeKey(assertSafeKey(key))}`;
const send = async (key: string, init: RequestInit) => {
const signed = await client.sign(objectUrl(key), init);
return doFetch(signed);
};
const presign = async (key: string, method: 'GET' | 'PUT', expiresIn: number, headers: Record<string, string> = {}) => {
const url = new URL(objectUrl(key));
url.searchParams.set('X-Amz-Expires', String(Math.min(Math.max(1, Math.floor(expiresIn)), 604_800)));
const signer = new AwsV4Signer({
url: url.toString(),
method,
headers,
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey,
service: 's3',
region,
signQuery: true,
// Firma también content-type y content-length: el navegador no puede subir otro tipo ni otro tamaño.
allHeaders: true,
});
return (await signer.sign()).url.toString();
};
const info = (key: string, res: Response) => ({
key,
size: Number(res.headers.get('content-length') ?? 0),
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
});
return {
driver: 's3',
async put(key: string, body: PutBody, opts: PutOptions) {
const bytes = await toBytes(body);
const headers: Record<string, string> = { 'content-type': opts.contentType };
if (opts.cacheControl) headers['cache-control'] = opts.cacheControl;
const res = await send(key, { method: 'PUT', body: bytes as Uint8Array<ArrayBuffer>, headers });
if (!res.ok) throw new StorageError('upstream', `S3 PUT ${res.status}`);
return { key, size: bytes.byteLength, contentType: opts.contentType };
},
async get(key) {
const res = await send(key, { method: 'GET' });
if (res.status === 404) return null;
if (!res.ok || !res.body) throw new StorageError('upstream', `S3 GET ${res.status}`);
return { ...info(key, res), body: res.body };
},
async head(key) {
const res = await send(key, { method: 'HEAD' });
if (res.status === 404) return null;
if (!res.ok) throw new StorageError('upstream', `S3 HEAD ${res.status}`);
return info(key, res);
},
async delete(key) {
const res = await send(key, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new StorageError('upstream', `S3 DELETE ${res.status}`);
},
async presignUpload(key: string, opts: PresignUploadOptions) {
checkSize(opts);
const expiresIn = opts.expiresIn ?? 600;
const headers = { 'content-type': opts.contentType, 'content-length': String(opts.size) };
const url = await presign(key, 'PUT', expiresIn, headers);
// content-length lo pone el navegador; solo hace falta enviar content-type.
return { url, method: 'PUT', headers: { 'content-type': opts.contentType }, expiresAt: new Date(Date.now() + expiresIn * 1000) };
},
presignDownload: (key, expiresIn = 300) => presign(key, 'GET', expiresIn),
publicUrl(key) {
assertSafeKey(key);
if (!isPublicKey(key) || !cfg.publicUrl) throw new StorageError('not_public', `not a public key: ${key}`);
return `${cfg.publicUrl.replace(/\/+$/, '')}/${encodeKey(key)}`;
},
};
}
export function checkSize(opts: PresignUploadOptions): void {
if (!Number.isInteger(opts.size) || opts.size <= 0 || opts.size > opts.maxBytes)
throw new StorageError('too_large', `size ${opts.size} exceeds ${opts.maxBytes} bytes`);
}
This package declares no MCP servers.
| Version | Commit | Published | Scan |
|---|---|---|---|
| 1.0.1 | 5b6a9cc | 2 hours ago | scan passed |
- genpm
- none
- proposed
- GenPM proposes the npm command and runs it only if you say yes.
- Used by (1)
- @core/media ^1.0.0
- scan
- scan passed · 0 findings
- commit
- v1.0.1 → 5b6a9cc5574ad638d71214af234145082f01cb98 · verified after fetch
- scripts
- None. GenPM never runs package code.
- license
- MIT
- Quality
- 100/100
- Recognized licensepassed
- AGENTS.md explains its purposepassed
- AGENTS.md has integration stepspassed
- AGENTS.md lists conventions or don'tspassed
- Includes testspassed
- Security scan passedpassed
- Released in the last 6 monthspassed
- Verified publisherpassed
- Summary and keywordspassed
- report
- See something wrong?