feat: Implement Cloudflare KV as an alternative attachment storage backend

This commit is contained in:
qaz741wsd856 2026-01-17 11:46:25 +00:00
parent 291b21015f
commit 392317a602
8 changed files with 303 additions and 121 deletions

View file

@ -15,7 +15,7 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem
## Features
* **Core Vault Functionality:** Create, read, update, and delete ciphers and folders.
* **File Attachments:** Optional Cloudflare R2 storage for attachments.
* **File Attachments:** Optional Cloudflare KV or R2 storage for attachments.
* **TOTP Support:** Store and generate Time-based One-Time Passwords.
* **Bitwarden Compatible:** Works with official Bitwarden clients.
* **Free to Host:** Runs on Cloudflare's free tier.
@ -25,7 +25,17 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem
### Attachments Support
Warden supports file attachments using Cloudflare R2 storage. Attachments are optional and require manual configuration to enable. See the deployment guide for setup details. Be aware that R2 may incur additional costs; see [Cloudflare R2 pricing](https://developers.cloudflare.com/r2/pricing/).
Warden supports file attachments using either **Cloudflare KV** or **Cloudflare R2** as the storage backend:
| Feature | KV | R2 |
|---------|----|----|
| Max file size | **25 MB** (hard limit) | 100 MB (By request body size limit of Workers) |
| Credit card required | **No** | Yes |
| Streaming I/O | Yes | Yes |
**Backend selection:** R2 takes priority — if R2 is configured, it will be used. Otherwise, KV is used.
See the [deployment guide](docs/deployment.md) for setup details. R2 may incur additional costs; see [Cloudflare R2 pricing](https://developers.cloudflare.com/r2/pricing/).
## Current Status

View file

@ -19,7 +19,7 @@ This page covers the two deployment paths. Pick the one that fits your workflow
3. **(Optional) Enable R2 Bucket for Attachments:**
If you want to use file attachments:
Warden uses KV for attachments storage by default. If you want to use R2 as storage backend:
```bash
# Create the production bucket
@ -28,7 +28,7 @@ This page covers the two deployment paths. Pick the one that fits your workflow
Then enable the R2 binding in `wrangler.toml` by uncommenting the R2 bucket configuration sections.
**Note:** Attachments are optional. If you don't enable R2 bindings, attachment functionality will be disabled but all other features will work normally.
**Note:** Attachments are optional. If you remove both KV and R2 bindings, attachment functionality will be disabled but all other features will work normally.
4. **Configure your Database ID:**
@ -168,7 +168,7 @@ If you skip seeding, `/api/settings/domains` and `/api/sync` will return `global
3. **(Optional) Enable R2 bucket for attachments:**
If you want to use file attachments:
Warden uses KV for attachments storage by default. If you want to use R2 as storage backend:
1. **Create R2 buckets in Cloudflare Dashboard before running the action:**
- Go to **Storage & databases****R2** → **Create bucket**

View file

@ -109,6 +109,26 @@ function getEnvVar(env, name, defaultValue = null) {
}
}
// Storage backend constants
const KV_MAX_VALUE_BYTES = 25 * 1024 * 1024; // 25 MiB (KV hard limit)
// Detect which storage backend is available.
// Priority: R2 if bound, otherwise KV.
function getStorageBackend(env) {
if (env.ATTACHMENTS_BUCKET) {
return "r2";
}
if (env.ATTACHMENTS_KV) {
return "kv";
}
return null;
}
// Check if using KV backend (for behavior differences)
function isKvBackend(env) {
return getStorageBackend(env) === "kv";
}
// Get attachment size limits from env
function getAttachmentMaxBytes(env) {
const value = getEnvVar(env, "ATTACHMENT_MAX_BYTES");
@ -163,11 +183,17 @@ async function enforceLimits(db, env, userId, newSize, excludeAttachmentId) {
throw new Error("Attachment size cannot be negative");
}
// KV has a hard 25MB limit per value
if (isKvBackend(env) && newSize > KV_MAX_VALUE_BYTES) {
throw new Error(`Attachment size exceeds KV limit (max ${KV_MAX_VALUE_BYTES / 1024 / 1024}MB)`);
}
const maxBytes = getAttachmentMaxBytes(env);
if (maxBytes !== null && newSize > maxBytes) {
throw new Error("Attachment size exceeds limit");
}
// Check total storage limit
const limitBytes = getTotalLimitBytes(env);
if (limitBytes !== null) {
const used = await getUserAttachmentUsage(db, userId, excludeAttachmentId);
@ -178,11 +204,11 @@ async function enforceLimits(db, env, userId, newSize, excludeAttachmentId) {
}
}
// Handle azure-upload with zero-copy streaming
// Handle azure-upload with zero-copy streaming (R2) or arrayBuffer (KV)
export async function handleAzureUpload(request, env, cipherId, attachmentId, token) {
// Get R2 bucket
const bucket = env.ATTACHMENTS_BUCKET;
if (!bucket) {
// Check storage backend
const backend = getStorageBackend(env);
if (!backend) {
return new Response(JSON.stringify({ error: "Attachments are not enabled" }), {
status: 400,
headers: { "Content-Type": "application/json" },
@ -297,46 +323,63 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to
});
}
// Build R2 key
const r2Key = `${cipherId}/${attachmentId}`;
// Build storage key
const storageKey = `${cipherId}/${attachmentId}`;
let uploadedSize;
// Prepare R2 put options
const putOptions = {};
const contentType = request.headers.get("Content-Type");
if (contentType) {
putOptions.httpMetadata = { contentType };
}
// Upload to R2 directly using request.body (zero-copy streaming)
let r2Object;
try {
r2Object = await bucket.put(r2Key, request.body, putOptions);
} catch (err) {
// Try to clean up on failure
if (backend === "kv") {
// KV backend: streaming upload (KV.put accepts ReadableStream)
// Note: KV doesn't return the uploaded size, so we trust Content-Length
const kv = env.ATTACHMENTS_KV;
try {
await bucket.delete(r2Key);
} catch {
// Ignore cleanup errors
await kv.put(storageKey, request.body);
} catch (err) {
return new Response(JSON.stringify({ error: `Upload failed: ${err.message}` }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// KV doesn't return actual size, trust Content-Length
uploadedSize = contentLength;
} else {
// R2 backend: streaming upload (zero-copy)
const bucket = env.ATTACHMENTS_BUCKET;
const putOptions = {};
const contentType = request.headers.get("Content-Type");
if (contentType) {
putOptions.httpMetadata = { contentType };
}
return new Response(JSON.stringify({ error: `Upload failed: ${err.message}` }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
const uploadedSize = r2Object.size;
// Verify uploaded size matches Content-Length
if (uploadedSize !== contentLength) {
let r2Object;
try {
await bucket.delete(r2Key);
} catch {
// Ignore cleanup errors
r2Object = await bucket.put(storageKey, request.body, putOptions);
} catch (err) {
// Try to clean up on failure
try {
await bucket.delete(storageKey);
} catch {
// Ignore cleanup errors
}
return new Response(JSON.stringify({ error: `Upload failed: ${err.message}` }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
uploadedSize = r2Object.size;
// Verify uploaded size matches Content-Length
if (uploadedSize !== contentLength) {
try {
await bucket.delete(storageKey);
} catch {
// Ignore cleanup errors
}
return new Response(JSON.stringify({ error: "Content-Length does not match uploaded size" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Content-Length does not match uploaded size" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Finalize upload: move pending -> attachments and touch revision timestamps
@ -364,11 +407,11 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to
return new Response(null, { status: 201 });
}
// Handle download with zero-copy streaming
// Handle download with zero-copy streaming (R2) or ArrayBuffer (KV)
export async function handleDownload(request, env, cipherId, attachmentId, token) {
// Get R2 bucket
const bucket = env.ATTACHMENTS_BUCKET;
if (!bucket) {
// Check storage backend
const backend = getStorageBackend(env);
if (!backend) {
return new Response(JSON.stringify({ error: "Attachments are not enabled" }), {
status: 400,
headers: { "Content-Type": "application/json" },
@ -442,27 +485,44 @@ export async function handleDownload(request, env, cipherId, attachmentId, token
});
}
// Build R2 key
const r2Key = `${cipherId}/${attachmentId}`;
// Build storage key
const storageKey = `${cipherId}/${attachmentId}`;
// Get object from R2
const r2Object = await bucket.get(r2Key);
if (!r2Object) {
return new Response(JSON.stringify({ error: "Attachment not found in storage" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
if (backend === "kv") {
// KV backend: streaming download
// Note: KV stream doesn't provide size info, use attachment.file_size from DB
const kv = env.ATTACHMENTS_KV;
const stream = await kv.get(storageKey, { type: "stream" });
if (!stream) {
return new Response(JSON.stringify({ error: "Attachment not found in storage" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const headers = new Headers();
headers.set("Content-Type", "application/octet-stream");
headers.set("Content-Length", attachment.file_size.toString());
return new Response(stream, { status: 200, headers });
} else {
// R2 backend: streaming download (zero-copy)
const bucket = env.ATTACHMENTS_BUCKET;
const r2Object = await bucket.get(storageKey);
if (!r2Object) {
return new Response(JSON.stringify({ error: "Attachment not found in storage" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// Build response headers
const headers = new Headers();
const contentType = r2Object.httpMetadata?.contentType || "application/octet-stream";
headers.set("Content-Type", contentType);
headers.set("Content-Length", r2Object.size.toString());
// Return response with R2 object body directly - zero-copy streaming
return new Response(r2Object.body, { status: 200, headers });
}
// Build response headers
const headers = new Headers();
const contentType = r2Object.httpMetadata?.contentType || "application/octet-stream";
headers.set("Content-Type", contentType);
headers.set("Content-Length", r2Object.size.toString());
// Return response with R2 object body directly - zero-copy streaming
return new Response(r2Object.body, {
status: 200,
headers,
});
}

View file

@ -584,9 +584,8 @@ pub async fn delete_account(
}
if attachments::attachments_enabled(env.as_ref()) {
let bucket = attachments::require_bucket(env.as_ref())?;
let keys = attachments::list_attachment_keys_for_user(&db, user_id).await?;
attachments::delete_r2_objects(&bucket, &keys).await?;
attachments::delete_storage_objects(env.as_ref(), &keys).await?;
}
// Delete all user's ciphers

View file

@ -25,8 +25,36 @@ use crate::{
};
const ATTACHMENTS_BUCKET: &str = "ATTACHMENTS_BUCKET";
const ATTACHMENTS_KV: &str = "ATTACHMENTS_KV";
const SIZE_LEEWAY_BYTES: i64 = 1024 * 1024; // 1 MiB
const DEFAULT_ATTACHMENT_TTL_SECS: i64 = 300; // 5 minutes
const KV_MAX_VALUE_BYTES: i64 = 25 * 1024 * 1024; // 25 MiB (KV hard limit)
/// Storage backend for attachments
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum StorageBackend {
/// Cloudflare KV - no credit card required, 25MB limit per value
KV,
/// Cloudflare R2 - requires credit card, no practical size limit
R2,
}
/// Detect which storage backend is available.
/// Priority: R2 if bound, otherwise KV.
pub(crate) fn get_storage_backend(env: &Env) -> Option<StorageBackend> {
if env.bucket(ATTACHMENTS_BUCKET).is_ok() {
Some(StorageBackend::R2)
} else if env.kv(ATTACHMENTS_KV).is_ok() {
Some(StorageBackend::KV)
} else {
None
}
}
/// Check if using KV backend (for behavior differences)
pub(crate) fn is_kv_backend(env: &Env) -> bool {
get_storage_backend(env) == Some(StorageBackend::KV)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@ -112,8 +140,12 @@ pub async fn create_attachment_v2(
Path(cipher_id): Path<String>,
Json(payload): Json<AttachmentCreateRequest>,
) -> Result<Json<AttachmentUploadResponse>, AppError> {
// Require bucket; fail directly if missing
let _bucket = require_bucket(&env)?;
// Require storage backend; fail directly if missing
if !attachments_enabled(&env) {
return Err(AppError::BadRequest(
"Attachments are not enabled".to_string(),
));
}
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -203,7 +235,11 @@ pub async fn upload_attachment_v2_data(
Path((cipher_id, attachment_id)): Path<(String, String)>,
mut multipart: Multipart,
) -> Result<Json<()>, AppError> {
let bucket = require_bucket(&env)?;
if !attachments_enabled(&env) {
return Err(AppError::BadRequest(
"Attachments are not enabled".to_string(),
));
}
let db = db::get_db(&env)?;
let _cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -219,17 +255,20 @@ pub async fn upload_attachment_v2_data(
read_multipart(&mut multipart).await?;
let actual_size = file_bytes.len() as i64;
// Validate actual size against declared value deviation
if let Err(e) = validate_size_within_declared(&pending, actual_size) {
query!(
&db,
"DELETE FROM attachments_pending WHERE id = ?1",
pending.id
)
.map_err(|_| AppError::Database)?
.run()
.await?;
return Err(e);
// For R2 backend: validate actual size against declared value deviation
// For KV backend: skip this check (we trust the actual upload size)
if !is_kv_backend(&env) {
if let Err(e) = validate_size_within_declared(&pending, actual_size) {
query!(
&db,
"DELETE FROM attachments_pending WHERE id = ?1",
pending.id
)
.map_err(|_| AppError::Database)?
.run()
.await?;
return Err(e);
}
}
// Validate capacity limits (replace with actual size)
@ -245,14 +284,8 @@ pub async fn upload_attachment_v2_data(
pending.akey = Some(k);
}
// Save to R2
upload_to_r2(
&bucket,
&pending.r2_key(),
content_type,
file_bytes.to_vec(),
)
.await?;
// Save to storage (KV or R2)
upload_to_storage(&env, &pending.r2_key(), content_type, file_bytes.to_vec()).await?;
// Finalize: move pending -> attachments and touch timestamps
let now = now_string();
@ -297,7 +330,11 @@ pub async fn upload_attachment_legacy(
Path(cipher_id): Path<String>,
mut multipart: Multipart,
) -> Result<Json<Cipher>, AppError> {
let bucket = require_bucket(&env)?;
if !attachments_enabled(&env) {
return Err(AppError::BadRequest(
"Attachments are not enabled".to_string(),
));
}
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -336,9 +373,9 @@ pub async fn upload_attachment_legacy(
.run()
.await?;
// Save to R2
upload_to_r2(
&bucket,
// Save to storage (KV or R2)
upload_to_storage(
&env,
&format!("{}/{}", cipher_id, attachment_id),
content_type,
file_bytes.to_vec(),
@ -363,7 +400,11 @@ pub async fn get_attachment(
Extension(BaseUrl(base_url)): Extension<BaseUrl>,
Path((cipher_id, attachment_id)): Path<(String, String)>,
) -> Result<Json<AttachmentResponse>, AppError> {
let _bucket = require_bucket(&env)?;
if !attachments_enabled(&env) {
return Err(AppError::BadRequest(
"Attachments are not enabled".to_string(),
));
}
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -386,7 +427,11 @@ pub async fn delete_attachment(
State(env): State<Arc<Env>>,
Path((cipher_id, attachment_id)): Path<(String, String)>,
) -> Result<Json<AttachmentDeleteResponse>, AppError> {
let bucket = require_bucket(&env)?;
if !attachments_enabled(&env) {
return Err(AppError::BadRequest(
"Attachments are not enabled".to_string(),
));
}
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -398,8 +443,8 @@ pub async fn delete_attachment(
));
}
// Delete R2 object; ignore missing objects
delete_r2_objects(&bucket, &[attachment.r2_key()]).await?;
// Delete storage object; ignore missing objects
delete_storage_objects(&env, &[attachment.r2_key()]).await?;
query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id)
.map_err(|_| AppError::Database)?
@ -453,12 +498,7 @@ pub async fn hydrate_cipher_attachments(
}
pub(crate) fn attachments_enabled(env: &Env) -> bool {
env.bucket(ATTACHMENTS_BUCKET).is_ok()
}
pub(crate) fn require_bucket(env: &Env) -> Result<Bucket, AppError> {
env.bucket(ATTACHMENTS_BUCKET)
.map_err(|_| AppError::BadRequest("Attachments are not enabled".to_string()))
get_storage_backend(env).is_some()
}
fn is_not_found_error(err: &worker::Error) -> bool {
@ -477,6 +517,30 @@ pub(crate) async fn delete_r2_objects(bucket: &Bucket, keys: &[String]) -> Resul
Ok(())
}
/// Delete objects from storage (KV or R2 based on configured backend)
pub(crate) async fn delete_storage_objects(env: &Env, keys: &[String]) -> Result<(), AppError> {
match get_storage_backend(env) {
Some(StorageBackend::KV) => {
let kv = env.kv(ATTACHMENTS_KV).map_err(|_| AppError::Internal)?;
for key in keys {
// KV delete is idempotent - no error if key doesn't exist
if let Err(e) = kv.delete(key).await {
log::error!("KV delete error for key '{}': {:?}", key, e);
return Err(AppError::Internal);
}
}
Ok(())
}
Some(StorageBackend::R2) => {
let bucket = env
.bucket(ATTACHMENTS_BUCKET)
.map_err(|_| AppError::Internal)?;
delete_r2_objects(&bucket, keys).await
}
None => Ok(()), // No-op if attachments not enabled
}
}
fn map_rows_to_keys(rows: Vec<AttachmentKeyRow>) -> Vec<String> {
rows.into_iter()
.map(|row| format!("{}/{}", row.cipher_id, row.id))
@ -672,6 +736,40 @@ async fn upload_to_r2(
Ok(())
}
/// Upload data to storage (KV or R2 based on configured backend)
async fn upload_to_storage(
env: &Env,
key: &str,
_content_type: Option<String>,
data: Vec<u8>,
) -> Result<(), AppError> {
match get_storage_backend(env) {
Some(StorageBackend::KV) => {
let kv = env.kv(ATTACHMENTS_KV).map_err(|_| AppError::Internal)?;
// KV put_bytes stores raw binary data
if let Err(e) = kv
.put_bytes(key, &data)
.map_err(|_| AppError::Internal)?
.execute()
.await
{
log::error!("KV put error for key '{}': {:?}", key, e);
return Err(AppError::Internal);
}
Ok(())
}
Some(StorageBackend::R2) => {
let bucket = env
.bucket(ATTACHMENTS_BUCKET)
.map_err(|_| AppError::Internal)?;
upload_to_r2(&bucket, key, _content_type, data).await
}
None => Err(AppError::BadRequest(
"Attachments are not enabled".to_string(),
)),
}
}
async fn read_multipart(
multipart: &mut Multipart,
) -> Result<(Bytes, Option<String>, Option<String>, Option<String>), AppError> {
@ -823,6 +921,14 @@ async fn enforce_limits(
));
}
// KV has a hard 25MB limit per value
if is_kv_backend(env) && new_size > KV_MAX_VALUE_BYTES {
return Err(AppError::BadRequest(format!(
"Attachment size exceeds KV limit (max {}MB)",
KV_MAX_VALUE_BYTES / 1024 / 1024
)));
}
let max_bytes = attachment_max_bytes(env)?;
if let Some(max_bytes) = max_bytes {
if new_size as u64 > max_bytes {
@ -832,6 +938,7 @@ async fn enforce_limits(
}
}
// Check total storage limit
let limit_bytes = total_limit_bytes(env)?;
if let Some(limit_bytes) = limit_bytes {
let used = user_attachment_usage(db, user_id, exclude_attachment).await?;

View file

@ -398,7 +398,6 @@ pub async fn hard_delete_cipher(
let db = db::get_db(&env)?;
if attachments::attachments_enabled(env.as_ref()) {
let bucket = attachments::require_bucket(env.as_ref())?;
let id_json = serde_json::to_string(&[&id]).map_err(|_| AppError::Internal)?;
let keys = attachments::list_attachment_keys_for_cipher_ids_json(
&db,
@ -407,7 +406,7 @@ pub async fn hard_delete_cipher(
Some(&claims.sub),
)
.await?;
attachments::delete_r2_objects(&bucket, &keys).await?;
attachments::delete_storage_objects(env.as_ref(), &keys).await?;
}
query!(
@ -437,7 +436,6 @@ pub async fn hard_delete_ciphers_bulk(
let db = db::get_db(&env)?;
if attachments::attachments_enabled(env.as_ref()) {
let bucket = attachments::require_bucket(env.as_ref())?;
let keys = attachments::list_attachment_keys_for_cipher_ids_json(
&db,
&body,
@ -445,7 +443,7 @@ pub async fn hard_delete_ciphers_bulk(
Some(&claims.sub),
)
.await?;
attachments::delete_r2_objects(&bucket, &keys).await?;
attachments::delete_storage_objects(env.as_ref(), &keys).await?;
}
query!(
@ -710,9 +708,8 @@ pub async fn purge_vault(
}
if attachments::attachments_enabled(env.as_ref()) {
let bucket = attachments::require_bucket(env.as_ref())?;
let keys = attachments::list_attachment_keys_for_user(&db, user_id).await?;
attachments::delete_r2_objects(&bucket, &keys).await?;
attachments::delete_storage_objects(env.as_ref(), &keys).await?;
}
// Delete all user's ciphers (both active and soft-deleted)

View file

@ -119,13 +119,11 @@ pub async fn purge_deleted_ciphers(env: &Env) -> Result<u32, worker::Error> {
if count > 0 {
if attachments::attachments_enabled(env) {
let bucket = attachments::require_bucket(env)
.map_err(|e| worker::Error::RustError(e.to_string()))?;
let keys = attachments::list_attachment_keys_for_soft_deleted_before(&db, &cutoff_str)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
attachments::delete_r2_objects(&bucket, &keys)
attachments::delete_storage_objects(env, &keys)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
}

View file

@ -90,12 +90,20 @@ database_name = "vault1"
database_id = "${D1_DATABASE_ID}"
migrations_dir = "migrations"
# R2 bucket for file attachments (optional)
# Uncomment and configure this section to enable file attachments
# R2 bucket for file attachments (optional, requires credit card)
# R2 has priority: if R2 is configured, KV is ignored.
# R2 supports larger files and streaming uploads.
# Uncomment and configure this section to enable file attachments via R2:
# [[r2_buckets]]
# binding = "ATTACHMENTS_BUCKET"
# bucket_name = "warden-attachments"
# KV namespace for file attachments (optional, no credit card required)
# KV has a 25MB limit per file, but doesn't require credit card binding.
# If R2 is not configured, KV will be used for attachments.
[[kv_namespaces]]
binding = "ATTACHMENTS_KV"
[env.dev]
name = "warden-worker-dev"
keep_vars = true
@ -125,12 +133,15 @@ database_name = "vault1-dev"
database_id = "${D1_DATABASE_ID_DEV}"
migrations_dir = "migrations"
# R2 bucket for file attachments in dev environment (optional)
# Uncomment and configure this section to enable file attachments
# R2 bucket for file attachments in dev environment (optional, requires credit card)
# [[env.dev.r2_buckets]]
# binding = "ATTACHMENTS_BUCKET"
# bucket_name = "warden-attachments-dev"
# KV namespace for file attachments in dev environment (optional, no credit card required)
[[env.dev.kv_namespaces]]
binding = "ATTACHMENTS_KV"
# logs
[env.dev.observability]
[env.dev.observability.logs]