Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when a PinMe Cloudflare Worker needs R2 object storage, including secure file or image upload, streaming download, metadata lookup, deletion, listing, Range requests, or R2+D1 coordination. Guides AI to use PinMe's automatically injected env.R2 binding without R2 credentials or manual Wrangler configuration.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 69% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 579% | 0% |
| case-19 | ✗→✓ | ▲ Improved | 152% | 0% |
| case-10 | ✓→✓ | = Same ✓ | 126% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 211% | 0% |
Use the project-scoped R2 bucket that PinMe binds to every deployed Worker as env.R2. Do not create credentials, choose a bucket name, or edit generated Wrangler configuration.
PinMe rebuilds trusted Worker metadata on create, save, and update. Client metadata cannot replace the R2 binding.
| Binding | TypeScript type | Availability | | --- | --- | --- | | DB | D1Database | Always injected | | R2 | R2Bucket | Always injected; current project's bucket | | API_KEY | string | Always injected | | LLM_API_KEY | string | Always injected | | BASE_URL | string | Always injected | | WORKER_URL | string | Always injected | | PROJECT_NAME | string | Always injected |
Payment-specific bindings such as UNIWEB_SECRET are conditional and unrelated to R2 access.
Declare only the bindings used by the Worker module. R2 code normally starts with:
typescriptexport interface Env { R2: R2Bucket; PROJECT_NAME: string; WORKER_URL: string; }
When the same module coordinates file metadata in D1, also declare DB: D1Database as a required field.
Apply this sequence to every upload, download, metadata, delete, and list route:
textauthenticate request → authorize the project/user action → validate size and media policy → generate or normalize a scoped object key → call env.R2 → return a sanitized response
Use the application's existing authentication. The examples below accept a trusted userId that the route must obtain from verified identity claims, never from an untrusted request body or query parameter.
Keep object keys server-controlled. Prefer opaque IDs under an owner prefix:
typescriptconst FILE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function ownerPrefix(userId: string): string { if (!userId) throw new Error('Authenticated user id is required'); return `users/${encodeURIComponent(userId)}/files/`; } function objectKey(userId: string, fileId: string): string { if (!FILE_ID_RE.test(fileId)) throw new Error('Invalid file id'); return `${ownerPrefix(userId)}${fileId}`; }
Never accept a complete object key from the client. Reject empty identifiers, . or .. segments, backslashes, control characters, and any attempt to access another user's prefix.
Use small helpers and explicit business limits. Adapt the allowlist to the product rather than accepting every client-supplied media type.
typescriptconst MAX_UPLOAD_BYTES = 25 * 1024 * 1024; const ALLOWED_CONTENT_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/webp', 'application/pdf', ]); function json(data: unknown, status = 200): Response { return Response.json(data, { status }); } function safeDownloadName(value: string | null): string { const cleaned = (value || 'download') .replace(/[\r\n"\\]/g, '_') .replace(/[\x00-\x1f\x7f]/g, '') .trim(); return (cleaned || 'download').slice(0, 128); } function requestedFileId(request: Request): string | null { const url = new URL(request.url); const value = url.pathname.split('/').filter(Boolean).at(-1) || ''; return FILE_ID_RE.test(value) ? value : null; }
Client filenames and Content-Type are hints, not proof of content. For sensitive formats, inspect magic bytes or send the object through an asynchronous validation/scanning workflow before marking it ready.
Require authentication before calling this handler. Pass request.body directly to R2; do not call arrayBuffer(), text(), json(), formData(), or base64 conversion first.
typescriptasync function handleUpload( request: Request, env: Env, userId: string, ): Promise<Response> { if (!request.body) return json({ error: 'File body is required' }, 400); const lengthHeader = request.headers.get('content-length'); if (!lengthHeader) return json({ error: 'Content-Length is required' }, 411); const declaredSize = Number(lengthHeader); if (!Number.isSafeInteger(declaredSize) || declaredSize < 0) { return json({ error: 'Invalid Content-Length' }, 400); } if (declaredSize > MAX_UPLOAD_BYTES) { return json({ error: 'File is too large' }, 413); } const contentType = (request.headers.get('content-type') || '') .split(';', 1)[0] .trim() .toLowerCase(); if (!ALLOWED_CONTENT_TYPES.has(contentType)) { return json({ error: 'Unsupported media type' }, 400); } const fileId = crypto.randomUUID(); const key = objectKey(userId, fileId); const filename = safeDownloadName(request.headers.get('x-file-name')); const object = await env.R2.put(key, request.body, { httpMetadata: { contentType, contentDisposition: `attachment; filename="${filename}"`, }, customMetadata: { ownerId: userId }, }); if (object === null) return json({ error: 'Upload precondition failed' }, 412); // Content-Length is only a precheck. Enforce the actual stored size too. if (object.size > MAX_UPLOAD_BYTES) { await env.R2.delete(key); return json({ error: 'File is too large' }, 413); } return json({ id: fileId, size: object.size, etag: object.httpEtag }, 201); }
Do not return the bucket name or internal object-key layout. Return an opaque file ID that later routes resolve under the authenticated owner's prefix.
Validate a single Range header before passing it to R2. R2 may return null when the object does not exist, or metadata without a body when a conditional request fails.
typescriptfunction validRangeHeader(value: string | null): boolean { if (!value) return true; const match = /^bytes=(\d*)-(\d*)$/.exec(value); return Boolean(match && (match[1] || match[2])); } async function handleDownload( request: Request, env: Env, userId: string, ): Promise<Response> { const fileId = requestedFileId(request); if (!fileId) return json({ error: 'Invalid file id' }, 400); if (!validRangeHeader(request.headers.get('range'))) { return json({ error: 'Invalid Range header' }, 416); } const object = await env.R2.get(objectKey(userId, fileId), { onlyIf: request.headers, range: request.headers, }); if (object === null) return json({ error: 'Not found' }, 404); if (!('body' in object)) return new Response(null, { status: 412 }); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); headers.set('accept-ranges', 'bytes'); if (object.range) { const { offset, length } = object.range; headers.set( 'content-range', `bytes ${offset}-${offset + length - 1}/${object.size}`, ); headers.set('content-length', String(length)); } else { headers.set('content-length', String(object.size)); } return new Response(object.body, { status: object.range ? 206 : 200, headers, }); }
For routes backed by D1 metadata, authorize the D1 row's owner before calling env.R2.get. Do not infer ownership only from a client-provided path.
typescriptasync function handleHead( request: Request, env: Env, userId: string, ): Promise<Response> { const fileId = requestedFileId(request); if (!fileId) return json({ error: 'Invalid file id' }, 400); const object = await env.R2.head(objectKey(userId, fileId)); if (object === null) return new Response(null, { status: 404 }); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); headers.set('content-length', String(object.size)); return new Response(null, { status: 200, headers }); }
Use head() when only size, ETag, upload time, or metadata is needed. Do not download the body to answer metadata requests.
typescriptasync function handleDelete( request: Request, env: Env, userId: string, ): Promise<Response> { const fileId = requestedFileId(request); if (!fileId) return json({ error: 'Invalid file id' }, 400); const key = objectKey(userId, fileId); const object = await env.R2.head(key); if (object === null) return json({ error: 'Not found' }, 404); await env.R2.delete(key); return new Response(null, { status: 204 }); }
R2 can delete up to 1000 keys in one delete([...keys]) call. Batch deletion must still derive and authorize every key server-side.
Never list the whole bucket for an end-user request. Derive the prefix from verified identity and treat the cursor as opaque.
typescriptasync function handleList( request: Request, env: Env, userId: string, ): Promise<Response> { const cursor = new URL(request.url).searchParams.get('cursor'); if (cursor && cursor.length > 2048) { return json({ error: 'Invalid cursor' }, 400); } const page = await env.R2.list({ prefix: ownerPrefix(userId), cursor: cursor || undefined, limit: 100, include: ['httpMetadata', 'customMetadata'], }); return json({ objects: page.objects.map((object) => ({ id: object.key.slice(ownerPrefix(userId).length), size: object.size, uploaded: object.uploaded.toISOString(), etag: object.httpEtag, contentType: object.httpMetadata?.contentType, })), nextCursor: page.truncated ? page.cursor : null, }); }
An R2 list call returns at most 1000 entries and may return fewer than the requested limit when metadata is included. Continue only when page.truncated is true; never use objects.length === limit as the pagination condition.
Authenticate once in the router, derive a trusted userId, then pass it to the handlers. Return an Allow header for unsupported methods.
| Status | Meaning | | --- | --- | | 400 | Invalid file ID, body, cursor, or media type | | 401 | Missing or invalid authentication | | 403 | Authenticated but not allowed to access the object | | 404 | Object or owned metadata record not found | | 411 | A capped upload route requires Content-Length but it is absent | | 412 | Conditional R2 operation failed | | 413 | Business or platform upload limit exceeded | | 416 | Invalid or unsatisfiable Range request | | 500 | Sanitized internal storage failure |
Catch storage failures at the route boundary, log only non-sensitive context, and return a generic error. Never return a raw provider error, bucket name, credential, or internal object key. Translate a valid-but-unsatisfiable R2 Range failure to 416 without returning the provider error text.
R2 and D1 do not share a transaction. Use an explicit state transition when business metadata is required:
textinsert D1 row with status=pending → stream body to R2 → update D1 row to status=ready
Use request.body → env.R2.put for small and medium uploads. Streaming avoids Worker memory amplification but does not bypass the Cloudflare request-body limit for the account plan.
Use multipart only when the object exceeds that request limit or resumability is an explicit product requirement. A multipart API must:
Do not generate a public multipart controller by default. Multipart state and security are substantially more complex than a single streaming upload.
backend/wrangler.toml to add an R2 binding.R2Bucket mock.pinme update-worker or pinme save.| Do not | Use instead | | --- | --- | | Expose an unauthenticated upload route | Authenticate and authorize before every mutation | | Accept a complete object key from the client | Generate an opaque ID under a server-derived owner prefix | | Trust a user ID from JSON or query parameters | Derive identity from verified claims | | Read a large body into an ArrayBuffer or base64 string | Stream request.body directly into env.R2.put | | Store file bodies or base64 in D1 | Store bodies in R2 and searchable metadata in D1 | | List the whole bucket | Restrict with an owner prefix and paginate | | Stop pagination based on returned object count | Check page.truncated and return page.cursor | | Drop response metadata | Apply writeHttpMetadata, httpEtag, length, and Range headers | | Persist with fs or local directories | Use the injected R2 binding | | Add R2 keys or secrets to source/config | Use env.R2; PinMe owns the binding | | Edit generated Wrangler binding configuration | Deploy through pinme save or pinme update-worker |
Other measured skills in the registry, with their headline benchmark lift.