diff --git a/.changeset/storage-provider-readiness.md b/.changeset/storage-provider-readiness.md
new file mode 100644
index 000000000..790c2d6c5
--- /dev/null
+++ b/.changeset/storage-provider-readiness.md
@@ -0,0 +1,6 @@
+---
+"@croco/storage-cloudflare": patch
+"@croco/storage-cloudinary": patch
+---
+
+- Expose storage provider diagnostics/readiness, deterministic upstream Problem codes, shared conformance coverage, and optional live-smoke gates for Cloudflare Images and Cloudinary.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3d646906a..49074803f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -301,6 +301,8 @@ jobs:
- 'packages/retry-core/src/**'
- 'packages/rpc-codegen/src/**'
- 'packages/search-core/src/**'
+ - 'packages/storage-cloudflare/src/**'
+ - 'packages/storage-cloudinary/src/**'
- 'packages/storage-core/src/**'
- 'packages/tasks-qstash/src/**'
- 'packages/telemetry-api/src/**'
@@ -336,8 +338,12 @@ jobs:
- name: Build docs and check for drift
run: |
pnpm turbo run docs:build --force
+ mapfile -t generated_api_docs < <(git diff --name-only -- packages/docs/src/content/docs/api/)
+ if [ "${#generated_api_docs[@]}" -gt 0 ]; then
+ pnpm exec oxfmt --write "${generated_api_docs[@]}"
+ fi
if ! git diff --exit-code -- packages/docs/src/content/docs/api/; then
- echo "::error::API docs are out of sync. Run 'pnpm docs:build' locally and commit the changes."
+ echo "::error::API docs are out of sync. Run 'pnpm docs:build', format the changed API docs with oxfmt, then commit the changes."
exit 1
fi
diff --git a/README.md b/README.md
index 79702b019..8bb084fa5 100644
--- a/README.md
+++ b/README.md
@@ -388,12 +388,12 @@ Croco가 **완전한 SaaS 프레임워크**가 되기 위해 계획 중인 기
Adapter 경계와 공식 우선순위, compatibility certification checklist는 [Adapter Ecosystem](packages/docs/src/content/docs/en/reference/adapter-ecosystem.md)에 정의되어 있습니다. 성숙도 승급 기준은 [Provider Maturity Gates](packages/docs/src/content/docs/en/reference/provider-maturity.md)와 [Presentation Runtime Support](packages/docs/src/content/docs/en/reference/presentation-runtime-support.md)에 정의되어 있으며, package test 존재 여부만으로 production-ready나 certified compatibility를 의미하지 않습니다.
-| 상태 | 의미 | 패키지 수 |
-| ------------------- | ----------------------------------- | --------: |
-| 🟢 production-ready | 안정화, 적극 사용 권장 | 24 |
-| 🟡 beta | 기능 완성, 실사용 검증 중 | 56 |
-| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 29 |
-| ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 |
+| 상태 | 의미 | 전체 public 패키지 수 |
+| ------------------- | ----------------------------------- | --------------------: |
+| 🟢 production-ready | 안정화, 적극 사용 권장 | 24 |
+| 🟡 beta | 기능 완성, 실사용 검증 중 | 57 |
+| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 28 |
+| ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 |
### Extension & Adapter Matrix
@@ -428,8 +428,8 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수
| `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | - | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests |
| `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🔴 alpha/WIP | has package tests |
| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests |
-| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs | 🔴 alpha/WIP | has package tests |
-| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs
storage conformance
diagnostics
optional live smoke | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry
storage conformance
diagnostics
optional live smoke | 🟡 beta | has package tests |
| `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests |
| `@croco/tasks-qstash` | Tasks | QStash task runner | yes | yes | - | - | UPSTASH_QSTASH_TOKEN
UPSTASH_QSTASH_DESTINATION_URL | - | task publish
delay override
custom headers
deduplication id
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests |
| `@croco/triggers-qstash` | Triggers | QStash scheduler and webhook handler | yes | yes | - | - | QSTASH_TOKEN
public webhook URL | - | schedule publish
webhook verification
trigger dispatch
shared conformance
redacted schedule diagnostics
diagnostic-coded webhook failures | 🔴 alpha/WIP | has package tests |
@@ -542,6 +542,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수
| `@croco/rpc-codegen` | Protocol | `packages/rpc-codegen` | README, API, tests |
| `@croco/billing-polar` | Provider | `packages/billing-polar` | README, tests |
| `@croco/llm-openai` | Provider | `packages/llm-openai` | README, API, tests |
+| `@croco/storage-cloudinary` | Provider | `packages/storage-cloudinary` | README, API, tests |
| `@croco/storage-r2` | Provider | `packages/storage-r2` | README, tests |
| `@croco/architecture-policy` | Tooling | `packages/architecture-policy` | README, tests |
| `@croco/cli` | Tooling | `packages/cli` | README, tests |
@@ -583,8 +584,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수
| `@croco/ratelimit-upstash` | Provider | `packages/ratelimit-upstash` | README, API, tests |
| `@croco/search-drizzle` | Provider | `packages/search-drizzle` | README, tests |
| `@croco/search-meilisearch` | Provider | `packages/search-meilisearch` | README, tests |
-| `@croco/storage-cloudflare` | Provider | `packages/storage-cloudflare` | README, tests |
-| `@croco/storage-cloudinary` | Provider | `packages/storage-cloudinary` | README, tests |
+| `@croco/storage-cloudflare` | Provider | `packages/storage-cloudflare` | README, API, tests |
| `@croco/tasks-qstash` | Provider | `packages/tasks-qstash` | README, API, tests |
| `@croco/triggers-qstash` | Provider | `packages/triggers-qstash` | README, API, tests |
diff --git a/docs/package-catalog.json b/docs/package-catalog.json
index 88512438b..d96685dfd 100644
--- a/docs/package-catalog.json
+++ b/docs/package-catalog.json
@@ -226,6 +226,7 @@
"protocols-graphql",
"protocols-trpc",
"rpc-codegen",
+ "storage-cloudinary",
"storage-core",
"storage-r2",
"tasks-core",
@@ -267,7 +268,6 @@
"search-drizzle",
"search-meilisearch",
"storage-cloudflare",
- "storage-cloudinary",
"tasks-qstash",
"triggers-qstash"
]
@@ -608,14 +608,30 @@
"adapter": "Cloudflare Images provider",
"runtimes": ["node", "lambda"],
"requiredEnv": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_HASH"],
- "features": ["image upload", "transform URLs", "upload intents", "signed URLs"]
+ "features": [
+ "image upload",
+ "transform URLs",
+ "upload intents",
+ "signed URLs",
+ "storage conformance",
+ "diagnostics",
+ "optional live smoke"
+ ]
},
"storage-cloudinary": {
"domain": "Storage",
"adapter": "Cloudinary provider",
"runtimes": ["node", "lambda"],
"requiredEnv": ["CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY", "CLOUDINARY_API_SECRET"],
- "features": ["file upload", "transform URLs", "upload intents", "retry"]
+ "features": [
+ "file upload",
+ "transform URLs",
+ "upload intents",
+ "retry",
+ "storage conformance",
+ "diagnostics",
+ "optional live smoke"
+ ]
},
"storage-r2": {
"domain": "Storage",
diff --git a/docs/package-docs-baseline.json b/docs/package-docs-baseline.json
index 47dfe50cc..5e93b6a49 100644
--- a/docs/package-docs-baseline.json
+++ b/docs/package-docs-baseline.json
@@ -47,8 +47,6 @@
"ratelimit-upstash",
"search-drizzle",
"search-meilisearch",
- "storage-cloudflare",
- "storage-cloudinary",
"storage-r2",
"tasks-core",
"tasks-qstash",
diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md
index 46d283eaf..8e1c1bf43 100644
--- a/docs/package-docs-report.md
+++ b/docs/package-docs-report.md
@@ -9,7 +9,7 @@
| Public packages | 109 |
| Private packages skipped | 2 |
| Missing package README | 0 |
-| Missing generated API docs | 49 |
+| Missing generated API docs | 47 |
| Missing package test directory | 0 |
| Extension matrix packages | 41 |
@@ -63,8 +63,6 @@ None.
- `@croco/presentation-preset` (`packages/presentation-preset`) — legacy baseline
- `@croco/search-drizzle` (`packages/search-drizzle`) — legacy baseline
- `@croco/search-meilisearch` (`packages/search-meilisearch`) — legacy baseline
-- `@croco/storage-cloudflare` (`packages/storage-cloudflare`) — legacy baseline
-- `@croco/storage-cloudinary` (`packages/storage-cloudinary`) — legacy baseline
- `@croco/storage-r2` (`packages/storage-r2`) — legacy baseline
- `@croco/tasks-core` (`packages/tasks-core`) — legacy baseline
- `@croco/tenant-core` (`packages/tenant-core`) — legacy baseline
@@ -77,7 +75,7 @@ None.
| ------------------- | ---------------: |
| 🟢 production-ready | 0 |
| 🟡 beta | 29 |
-| 🔴 alpha/WIP | 20 |
+| 🔴 alpha/WIP | 18 |
| ⚠️ deprecated | 0 |
## Missing Test Directory
@@ -100,8 +98,8 @@ None.
| Maturity | Packages |
| ------------------- | -------: |
| 🟢 production-ready | 24 |
-| 🟡 beta | 56 |
-| 🔴 alpha/WIP | 29 |
+| 🟡 beta | 57 |
+| 🔴 alpha/WIP | 28 |
| ⚠️ deprecated | 0 |
## Extension Matrix
diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json
index eaf09da03..e022f370f 100644
--- a/docs/problem-code-registry.json
+++ b/docs/problem-code-registry.json
@@ -1,6 +1,6 @@
{
"version": "croco.problem-code-registry.v1",
- "problemCount": 367,
+ "problemCount": 375,
"problems": [
{
"code": "ACCESS_DENIED",
@@ -1859,7 +1859,7 @@
"sources": [
{
"file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts",
- "line": 44,
+ "line": 48,
"column": 13,
"kind": "problem-factory"
}
@@ -1886,7 +1886,7 @@
"sources": [
{
"file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts",
- "line": 206,
+ "line": 256,
"column": 13,
"kind": "problem-factory"
}
@@ -1913,7 +1913,7 @@
"sources": [
{
"file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts",
- "line": 267,
+ "line": 331,
"column": 13,
"kind": "problem-factory"
}
@@ -7886,6 +7886,114 @@
}
]
},
+ {
+ "code": "storage-cloudflare/missing-config",
+ "category": "InternalServerError",
+ "status": 500,
+ "title": "Internal Server Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-missing-config",
+ "recovery": {
+ "cause": "Croco or an upstream dependency failed after accepting the request.",
+ "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.",
+ "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.",
+ "retryability": "conditional",
+ "redactionPolicy": "operator-only",
+ "telemetry": {
+ "eventName": "croco.problem.error",
+ "severity": "error",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts",
+ "line": 37,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
+ {
+ "code": "storage-cloudflare/retryable-upstream",
+ "category": "InternalServerError",
+ "status": 500,
+ "title": "Internal Server Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-retryable-upstream",
+ "recovery": {
+ "cause": "Croco or an upstream dependency failed after accepting the request.",
+ "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.",
+ "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.",
+ "retryability": "conditional",
+ "redactionPolicy": "operator-only",
+ "telemetry": {
+ "eventName": "croco.problem.error",
+ "severity": "error",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts",
+ "line": 70,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
+ {
+ "code": "storage-cloudflare/terminal-upstream",
+ "category": "InternalServerError",
+ "status": 500,
+ "title": "Internal Server Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-terminal-upstream",
+ "recovery": {
+ "cause": "Croco or an upstream dependency failed after accepting the request.",
+ "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.",
+ "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.",
+ "retryability": "conditional",
+ "redactionPolicy": "operator-only",
+ "telemetry": {
+ "eventName": "croco.problem.error",
+ "severity": "error",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts",
+ "line": 86,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
+ {
+ "code": "storage-cloudflare/validation-failed",
+ "category": "ValidationError",
+ "status": 422,
+ "title": "Validation Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-validation-failed",
+ "recovery": {
+ "cause": "The request or generated contract failed schema or semantic validation.",
+ "userAction": "Fix the invalid fields and retry with schema-conformant input.",
+ "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.",
+ "retryability": "not-retryable",
+ "redactionPolicy": "public",
+ "telemetry": {
+ "eventName": "croco.problem.info",
+ "severity": "info",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts",
+ "line": 57,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
{
"code": "storage-cloudinary/invalid-upload-intent-ttl",
"category": "BadRequest",
@@ -7907,12 +8015,120 @@
"sources": [
{
"file": "packages/storage-cloudinary/src/libs/CloudinaryProvider.ts",
- "line": 396,
+ "line": 331,
"column": 13,
"kind": "problem-factory"
}
]
},
+ {
+ "code": "storage-cloudinary/missing-config",
+ "category": "InternalServerError",
+ "status": 500,
+ "title": "Internal Server Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-missing-config",
+ "recovery": {
+ "cause": "Croco or an upstream dependency failed after accepting the request.",
+ "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.",
+ "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.",
+ "retryability": "conditional",
+ "redactionPolicy": "operator-only",
+ "telemetry": {
+ "eventName": "croco.problem.error",
+ "severity": "error",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts",
+ "line": 51,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
+ {
+ "code": "storage-cloudinary/retryable-upstream",
+ "category": "InternalServerError",
+ "status": 500,
+ "title": "Internal Server Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-retryable-upstream",
+ "recovery": {
+ "cause": "Croco or an upstream dependency failed after accepting the request.",
+ "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.",
+ "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.",
+ "retryability": "conditional",
+ "redactionPolicy": "operator-only",
+ "telemetry": {
+ "eventName": "croco.problem.error",
+ "severity": "error",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts",
+ "line": 84,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
+ {
+ "code": "storage-cloudinary/terminal-upstream",
+ "category": "InternalServerError",
+ "status": 500,
+ "title": "Internal Server Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-terminal-upstream",
+ "recovery": {
+ "cause": "Croco or an upstream dependency failed after accepting the request.",
+ "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.",
+ "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.",
+ "retryability": "conditional",
+ "redactionPolicy": "operator-only",
+ "telemetry": {
+ "eventName": "croco.problem.error",
+ "severity": "error",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts",
+ "line": 100,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
+ {
+ "code": "storage-cloudinary/validation-failed",
+ "category": "ValidationError",
+ "status": 422,
+ "title": "Validation Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-validation-failed",
+ "recovery": {
+ "cause": "The request or generated contract failed schema or semantic validation.",
+ "userAction": "Fix the invalid fields and retry with schema-conformant input.",
+ "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.",
+ "retryability": "not-retryable",
+ "redactionPolicy": "public",
+ "telemetry": {
+ "eventName": "croco.problem.info",
+ "severity": "info",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "sources": [
+ {
+ "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts",
+ "line": 71,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
{
"code": "storage/invalid-upload-intent-ttl",
"category": "BadRequest",
@@ -7934,7 +8150,7 @@
"sources": [
{
"file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts",
- "line": 233,
+ "line": 283,
"column": 13,
"kind": "problem-factory"
}
diff --git a/packages/create-croco-app/src/tests/PublishedCli.spec.ts b/packages/create-croco-app/src/tests/PublishedCli.spec.ts
index 49d61f3f3..e62fac3e0 100644
--- a/packages/create-croco-app/src/tests/PublishedCli.spec.ts
+++ b/packages/create-croco-app/src/tests/PublishedCli.spec.ts
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
-import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -10,6 +10,13 @@ const __dirname = dirname(__filename);
const packageDir = resolve(__dirname, "../..");
const rootDir = resolve(packageDir, "../..");
const spawnTimeoutMs = 180_000;
+// These tarballs cover the packed CLI's local Croco runtime dependency graph, including transitive deps.
+const packedRuntimeWorkspacePackages = ["problems-core", "diagnostics-core", "telemetry-sdk-node"];
+const requiredPackageArtifacts = [
+ ...packedRuntimeWorkspacePackages.flatMap((packageName) => requiredLibraryArtifacts(packageName)),
+ join(packageDir, "dist", "index.js"),
+ join(packageDir, "dist", "index.d.ts"),
+];
describe("published create-croco-app CLI", () => {
it(
@@ -20,21 +27,7 @@ describe("published create-croco-app CLI", () => {
try {
ensureBuilt();
- run(
- "pnpm",
- ["--filter", "@croco/problems-core", "pack", "--pack-destination", packRoot],
- rootDir,
- );
- run(
- "pnpm",
- ["--filter", "@croco/diagnostics-core", "pack", "--pack-destination", packRoot],
- rootDir,
- );
- run(
- "pnpm",
- ["--filter", "@croco/telemetry-sdk-node", "pack", "--pack-destination", packRoot],
- rootDir,
- );
+ packRuntimeWorkspacePackages(packRoot);
run(
"pnpm",
["--filter", "create-croco-app", "pack", "--pack-destination", packRoot],
@@ -81,17 +74,37 @@ describe("published create-croco-app CLI", () => {
);
});
+function packRuntimeWorkspacePackages(packRoot: string): void {
+ for (const packageName of packedRuntimeWorkspacePackages) {
+ run(
+ "pnpm",
+ ["--filter", `@croco/${packageName}`, "pack", "--pack-destination", packRoot],
+ rootDir,
+ );
+ }
+}
+
function ensureBuilt(): void {
- if (
- existsSync(join(rootDir, "packages", "problems-core", "dist", "index.js")) &&
- existsSync(join(rootDir, "packages", "diagnostics-core", "dist", "index.js")) &&
- existsSync(join(rootDir, "packages", "telemetry-sdk-node", "dist", "index.js")) &&
- existsSync(join(packageDir, "dist", "index.js"))
- ) {
+ if (requiredPackageArtifacts.every((artifact) => existsSync(artifact))) {
return;
}
run("pnpm", ["--filter", "create-croco-app...", "build"], rootDir);
+
+ const missingArtifacts = requiredPackageArtifacts.filter((artifact) => !existsSync(artifact));
+ if (missingArtifacts.length > 0) {
+ throw new Error(
+ `Missing build artifacts after build:\n${missingArtifacts.map((artifact) => `- ${artifact}`).join("\n")}`,
+ );
+ }
+}
+
+function requiredLibraryArtifacts(packageName: string): string[] {
+ const distDir = join(rootDir, "packages", packageName, "dist");
+
+ return ["index.js", "index.mjs", "index.d.ts", "index.d.mts"].map((filename) =>
+ join(distDir, filename),
+ );
}
function findTarball(directory: string, prefix: string): string {
diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs
index 56264182f..26b5a8801 100644
--- a/packages/docs/astro.config.mjs
+++ b/packages/docs/astro.config.mjs
@@ -84,6 +84,8 @@ export default defineConfig({
"../retry-core/src/index.ts",
"../rpc-codegen/src/index.ts",
"../search-core/src/index.ts",
+ "../storage-cloudflare/src/index.ts",
+ "../storage-cloudinary/src/index.ts",
"../storage-core/src/index.ts",
"../telemetry-api/src/index.ts",
"../telemetry-sdk-node/src/index.ts",
diff --git a/packages/docs/package.json b/packages/docs/package.json
index 529546ccd..57bc25ee1 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -70,6 +70,8 @@
"@croco/retry-core": "workspace:*",
"@croco/rpc-codegen": "workspace:*",
"@croco/search-core": "workspace:*",
+ "@croco/storage-cloudflare": "workspace:*",
+ "@croco/storage-cloudinary": "workspace:*",
"@croco/storage-core": "workspace:*",
"@croco/tasks-qstash": "workspace:*",
"@croco/telemetry-api": "workspace:*",
diff --git a/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md b/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md
index cd58aed36..866c7a55a 100644
--- a/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md
+++ b/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md
@@ -177,6 +177,14 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다
- [`SearchCapabilityUnavailableProblem`](/api/search-core/src/classes/searchcapabilityunavailableproblem/)
- [`StrategyUnavailableProblem`](/api/search-core/src/classes/strategyunavailableproblem/)
- [`TransformNotFoundProblem`](/api/search-core/src/classes/transformnotfoundproblem/)
+- [`CloudflareImagesMissingConfigProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesmissingconfigproblem/)
+- [`CloudflareImagesRetryableUpstreamProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesretryableupstreamproblem/)
+- [`CloudflareImagesTerminalUpstreamProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesterminalupstreamproblem/)
+- [`CloudflareImagesValidationProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesvalidationproblem/)
+- [`CloudinaryMissingConfigProblem`](/api/storage-cloudinary/src/classes/cloudinarymissingconfigproblem/)
+- [`CloudinaryRetryableUpstreamProblem`](/api/storage-cloudinary/src/classes/cloudinaryretryableupstreamproblem/)
+- [`CloudinaryTerminalUpstreamProblem`](/api/storage-cloudinary/src/classes/cloudinaryterminalupstreamproblem/)
+- [`CloudinaryValidationProblem`](/api/storage-cloudinary/src/classes/cloudinaryvalidationproblem/)
- [`StorageProblem`](/api/storage-core/src/classes/storageproblem/)
- [`OtlpEndpointRequiredProblem`](/api/telemetry-sdk-node/src/classes/otlpendpointrequiredproblem/)
- [`SamplerProblem`](/api/telemetry-sdk-node/src/classes/samplerproblem/)
@@ -205,7 +213,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다
> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
-***
+---
### cause?
@@ -215,31 +223,31 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다
`Error.cause`
-***
+---
### code
> `readonly` **code**: `string`
-***
+---
### detail?
> `readonly` `optional` **detail**: `string`
-***
+---
### extensions?
> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
-***
+---
### instance?
> `readonly` `optional` **instance**: `string`
-***
+---
### message
@@ -249,7 +257,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다
`Error.message`
-***
+---
### name
@@ -259,7 +267,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다
`Error.name`
-***
+---
### stack?
@@ -269,13 +277,13 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다
`Error.stack`
-***
+---
### type
> `readonly` **type**: `string`
-***
+---
### stackTraceLimit
@@ -307,7 +315,7 @@ not capture any frames.
`number`
-***
+---
### title
@@ -329,7 +337,7 @@ not capture any frames.
[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
-***
+---
### captureStackTrace()
@@ -342,7 +350,7 @@ a string representing the location in the code at which
```js
const myObject = {};
Error.captureStackTrace(myObject);
-myObject.stack; // Similar to `new Error().stack`
+myObject.stack; // Similar to `new Error().stack`
```
The first line of the trace will be prefixed with
@@ -397,7 +405,7 @@ a();
`Error.captureStackTrace`
-***
+---
### prepareStackTrace()
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesDiagnosticsProvider.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesDiagnosticsProvider.md
new file mode 100644
index 000000000..bc7fb4d4a
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesDiagnosticsProvider.md
@@ -0,0 +1,60 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesDiagnosticsProvider"
+---
+
+## Implements
+
+- [`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudflareImagesDiagnosticsProvider**(`config`, `options?`): `CloudflareImagesDiagnosticsProvider`
+
+#### Parameters
+
+##### config
+
+`Partial`\<[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)\>
+
+##### options?
+
+[`CloudflareImagesDiagnosticsOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesdiagnosticsoptions/) = `{}`
+
+#### Returns
+
+`CloudflareImagesDiagnosticsProvider`
+
+## Properties
+
+### name
+
+> `readonly` **name**: `"storage-cloudflare"` = `"storage-cloudflare"`
+
+#### Implementation of
+
+[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`name`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#name)
+
+## Methods
+
+### getHealth()
+
+> **getHealth**(`signal?`): `Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\>
+
+#### Parameters
+
+##### signal?
+
+`AbortSignal`
+
+#### Returns
+
+`Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\>
+
+#### Implementation of
+
+[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`getHealth`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#gethealth)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesMissingConfigProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesMissingConfigProblem.md
new file mode 100644
index 000000000..93193a88e
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesMissingConfigProblem.md
@@ -0,0 +1,298 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesMissingConfigProblem"
+---
+
+Problem raised when Cloudflare Images storage is used without a required configuration value.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudflareImagesMissingConfigProblem**(`configKey`, `operation?`): `CloudflareImagesMissingConfigProblem`
+
+#### Parameters
+
+##### configKey
+
+[`CloudflareImagesConfigKey`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesconfigkey/)
+
+##### operation?
+
+`string` = `"configuration"`
+
+#### Returns
+
+`CloudflareImagesMissingConfigProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesProvider.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesProvider.md
new file mode 100644
index 000000000..110a71826
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesProvider.md
@@ -0,0 +1,334 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesProvider"
+---
+
+Cloudflare Images를 이용해 파일 저장과 이미지 변환 URL 생성을 제공하는 구현체입니다.
+
+## Extends
+
+- [`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/)
+
+## Implements
+
+- [`ImageProvider`](/api/storage-core/src/type-aliases/imageprovider/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudflareImagesProvider**(`options`): `CloudflareImagesProvider`
+
+#### Parameters
+
+##### options
+
+[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)
+
+#### Returns
+
+`CloudflareImagesProvider`
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`constructor`](/api/storage-core/src/classes/basestorageprovider/#constructor)
+
+## Methods
+
+### delete()
+
+> **delete**(`key`): `Promise`\<`void`\>
+
+파일 삭제
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`void`\>
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`delete`](/api/storage-core/src/classes/basestorageprovider/#delete)
+
+---
+
+### exists()
+
+> **exists**(`key`): `Promise`\<`boolean`\>
+
+파일 존재 여부 확인
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`boolean`\>
+
+#### Inherited from
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`exists`](/api/storage-core/src/classes/basestorageprovider/#exists)
+
+---
+
+### get()
+
+> **get**(`key`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\>
+
+파일 다운로드
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`Buffer`\<`ArrayBufferLike`\>\>
+
+파일 버퍼
+
+#### Throws
+
+FileNotFoundProblem - 파일이 존재하지 않을 때
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`get`](/api/storage-core/src/classes/basestorageprovider/#get)
+
+---
+
+### getMetadata()
+
+> **getMetadata**(`key`): `Promise`\<\{ `contentType?`: `string`; `etag?`: `string`; `lastModified`: `Date`; `size`: `number`; \}\>
+
+객체 메타데이터 조회
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<\{ `contentType?`: `string`; `etag?`: `string`; `lastModified`: `Date`; `size`: `number`; \}\>
+
+객체 메타데이터
+
+#### Throws
+
+FileNotFoundProblem - 파일이 존재하지 않을 때
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getMetadata`](/api/storage-core/src/classes/basestorageprovider/#getmetadata)
+
+---
+
+### getPublicUrl()
+
+> **getPublicUrl**(`key`): `string`
+
+공개 URL 반환
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`string`
+
+공개 액세스 가능한 URL
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getPublicUrl`](/api/storage-core/src/classes/basestorageprovider/#getpublicurl)
+
+---
+
+### getSignedUrl()
+
+> **getSignedUrl**(`key`, `options`): `Promise`\<`string`\>
+
+서명된 URL 반환 (임시 액세스)
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+##### options
+
+[`SignedUrlOptions`](/api/storage-core/src/type-aliases/signedurloptions/)
+
+만료 시간 등 옵션
+
+#### Returns
+
+`Promise`\<`string`\>
+
+서명된 URL
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getSignedUrl`](/api/storage-core/src/classes/basestorageprovider/#getsignedurl)
+
+---
+
+### getStream()
+
+> **getStream**(`key`): `Promise`\<`Readable`\>
+
+파일 스트림 다운로드
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`Readable`\>
+
+읽기 가능한 스트림
+
+#### Throws
+
+FileNotFoundProblem - 파일이 존재하지 않을 때
+
+#### Inherited from
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getStream`](/api/storage-core/src/classes/basestorageprovider/#getstream)
+
+---
+
+### getTransformUrl()
+
+> **getTransformUrl**(`key`, `options`): `string`
+
+변환된 이미지 URL 반환
+
+CDN에서 실시간으로 이미지를 변환하고 반환합니다.
+
+#### Parameters
+
+##### key
+
+`string`
+
+원본 이미지 식별자
+
+##### options
+
+[`TransformOptions`](/api/storage-core/src/type-aliases/transformoptions/)
+
+변환 옵션
+
+#### Returns
+
+`string`
+
+변환된 이미지의 공개 URL
+
+#### Implementation of
+
+`ImageProvider.getTransformUrl`
+
+---
+
+### getUploadIntent()
+
+> **getUploadIntent**(`key`, `options?`): `Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\>
+
+클라이언트 직접 업로드를 위한 의도 생성 (선택)
+
+#### Parameters
+
+##### key
+
+`string`
+
+업로드할 파일 식별자
+
+##### options?
+
+###### ttlInSeconds?
+
+`number`
+
+#### Returns
+
+`Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\>
+
+업로드 의도 정보
+
+#### Implementation of
+
+`ImageProvider.getUploadIntent`
+
+---
+
+### put()
+
+> **put**(`key`, `data`, `options?`): `Promise`\<`void`\>
+
+파일 업로드
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+##### data
+
+파일 데이터 (Buffer 또는 Readable 스트림)
+
+`Readable` | `Buffer`\<`ArrayBufferLike`\>
+
+##### options?
+
+[`PutOptions`](/api/storage-core/src/type-aliases/putoptions/)
+
+업로드 옵션
+
+#### Returns
+
+`Promise`\<`void`\>
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`put`](/api/storage-core/src/classes/basestorageprovider/#put)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesRetryableUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesRetryableUpstreamProblem.md
new file mode 100644
index 000000000..149da106f
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesRetryableUpstreamProblem.md
@@ -0,0 +1,294 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesRetryableUpstreamProblem"
+---
+
+RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudflareImagesRetryableUpstreamProblem**(`context`): `CloudflareImagesRetryableUpstreamProblem`
+
+#### Parameters
+
+##### context
+
+[`CloudflareImagesErrorContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimageserrorcontext/)
+
+#### Returns
+
+`CloudflareImagesRetryableUpstreamProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesTerminalUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesTerminalUpstreamProblem.md
new file mode 100644
index 000000000..75e9d1345
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesTerminalUpstreamProblem.md
@@ -0,0 +1,294 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesTerminalUpstreamProblem"
+---
+
+RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudflareImagesTerminalUpstreamProblem**(`context`): `CloudflareImagesTerminalUpstreamProblem`
+
+#### Parameters
+
+##### context
+
+[`CloudflareImagesErrorContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimageserrorcontext/)
+
+#### Returns
+
+`CloudflareImagesTerminalUpstreamProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesValidationProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesValidationProblem.md
new file mode 100644
index 000000000..ffe9863d8
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesValidationProblem.md
@@ -0,0 +1,298 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesValidationProblem"
+---
+
+RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudflareImagesValidationProblem**(`context`, `detail?`): `CloudflareImagesValidationProblem`
+
+#### Parameters
+
+##### context
+
+[`CloudflareImagesErrorContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimageserrorcontext/)
+
+##### detail?
+
+`string` = `"Cloudflare Images request validation failed"`
+
+#### Returns
+
+`CloudflareImagesValidationProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/createCloudflareImagesResponseProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/createCloudflareImagesResponseProblem.md
new file mode 100644
index 000000000..2979b5055
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/createCloudflareImagesResponseProblem.md
@@ -0,0 +1,36 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "createCloudflareImagesResponseProblem"
+---
+
+> **createCloudflareImagesResponseProblem**(`options`): [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Parameters
+
+### options
+
+#### detail?
+
+`string`
+
+#### key?
+
+`string`
+
+#### operation
+
+`string`
+
+#### status?
+
+`number`
+
+#### upstreamCode?
+
+`string`
+
+## Returns
+
+[`Problem`](/api/problems-core/src/classes/problem/)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/normalizeCloudflareImagesError.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/normalizeCloudflareImagesError.md
new file mode 100644
index 000000000..fdc09b0cc
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/normalizeCloudflareImagesError.md
@@ -0,0 +1,36 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "normalizeCloudflareImagesError"
+---
+
+> **normalizeCloudflareImagesError**(`error`, `options`): [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Parameters
+
+### error
+
+`unknown`
+
+### options
+
+#### key?
+
+`string`
+
+#### operation
+
+`string`
+
+#### status?
+
+`number`
+
+#### upstreamCode?
+
+`string`
+
+## Returns
+
+[`Problem`](/api/problems-core/src/classes/problem/)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/validateCloudflareImagesOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/validateCloudflareImagesOptions.md
new file mode 100644
index 000000000..b919588c3
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/validateCloudflareImagesOptions.md
@@ -0,0 +1,18 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "validateCloudflareImagesOptions"
+---
+
+> **validateCloudflareImagesOptions**(`config`): [`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)
+
+## Parameters
+
+### config
+
+`Partial`\<[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)\>
+
+## Returns
+
+[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImageDetails.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImageDetails.md
new file mode 100644
index 000000000..251bdfa81
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImageDetails.md
@@ -0,0 +1,84 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImageDetails"
+---
+
+> **CloudflareImageDetails** = `object`
+
+Cloudflare Images 상세 조회 응답 구조입니다.
+
+## Properties
+
+### errors
+
+> **errors**: `unknown`[]
+
+에러 목록
+
+---
+
+### messages
+
+> **messages**: `unknown`[]
+
+메시지 목록
+
+---
+
+### result
+
+> **result**: \{ `filename`: `string`; `id`: `string`; `requireSignedURLs`: `boolean`; `size?`: `number`; `uploaded`: `string`; `variants`: `string`[]; \} \| `null`
+
+이미지 상세 정보
+
+#### Type Declaration
+
+\{ `filename`: `string`; `id`: `string`; `requireSignedURLs`: `boolean`; `size?`: `number`; `uploaded`: `string`; `variants`: `string`[]; \}
+
+#### filename
+
+> **filename**: `string`
+
+원본 파일명
+
+#### id
+
+> **id**: `string`
+
+이미지 ID
+
+#### requireSignedURLs
+
+> **requireSignedURLs**: `boolean`
+
+서명된 URL 필요 여부
+
+#### size?
+
+> `optional` **size**: `number`
+
+이미지 크기 (bytes)
+
+#### uploaded
+
+> **uploaded**: `string`
+
+업로드 시간
+
+#### variants
+
+> **variants**: `string`[]
+
+가능한 변성(variants) URL 목록
+
+`null`
+
+---
+
+### success
+
+> **success**: `boolean`
+
+성공 여부
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesConfigKey.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesConfigKey.md
new file mode 100644
index 000000000..9c00d2450
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesConfigKey.md
@@ -0,0 +1,10 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesConfigKey"
+---
+
+> **CloudflareImagesConfigKey** = `"accountHash"` \| `"accountId"` \| `"apiToken"`
+
+Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다.
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesDiagnosticsOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesDiagnosticsOptions.md
new file mode 100644
index 000000000..8cb1561e2
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesDiagnosticsOptions.md
@@ -0,0 +1,26 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesDiagnosticsOptions"
+---
+
+> **CloudflareImagesDiagnosticsOptions** = `object`
+
+Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다.
+
+## Properties
+
+### readinessCheck()?
+
+> `readonly` `optional` **readinessCheck**: (`context`) => `Promise`\<[`CloudflareImagesReadinessCheckResult`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesreadinesscheckresult/) \| `void`\>
+
+#### Parameters
+
+##### context
+
+[`CloudflareImagesReadinessCheckContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesreadinesscheckcontext/)
+
+#### Returns
+
+`Promise`\<[`CloudflareImagesReadinessCheckResult`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesreadinesscheckresult/) \| `void`\>
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesErrorContext.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesErrorContext.md
new file mode 100644
index 000000000..7f943f7e6
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesErrorContext.md
@@ -0,0 +1,46 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesErrorContext"
+---
+
+> **CloudflareImagesErrorContext** = `object`
+
+Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다.
+
+## Properties
+
+### key?
+
+> `readonly` `optional` **key**: `string`
+
+---
+
+### operation
+
+> `readonly` **operation**: `string`
+
+---
+
+### provider
+
+> `readonly` **provider**: `"cloudflare-images"`
+
+---
+
+### retryable?
+
+> `readonly` `optional` **retryable**: `boolean`
+
+---
+
+### status?
+
+> `readonly` `optional` **status**: `number`
+
+---
+
+### upstreamCode?
+
+> `readonly` `optional` **upstreamCode**: `string`
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesOptions.md
new file mode 100644
index 000000000..3c079c8f0
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesOptions.md
@@ -0,0 +1,72 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesOptions"
+---
+
+> **CloudflareImagesOptions** = `object`
+
+Cloudflare Images 제공자 설정입니다.
+
+## Properties
+
+### accountHash
+
+> **accountHash**: `string`
+
+Cloudflare Account Hash (공개 URL용)
+
+---
+
+### accountId
+
+> **accountId**: `string`
+
+Cloudflare Account ID
+
+---
+
+### apiToken
+
+> **apiToken**: `string`
+
+Cloudflare API Token (Images API 권한 필요)
+
+---
+
+### customDomain?
+
+> `optional` **customDomain**: `string`
+
+커스텀 도메인 (선택)
+설정된 경우 커스텀 도메인을 통해 이미지 제공
+
+---
+
+### defaultVariant?
+
+> `optional` **defaultVariant**: `string`
+
+기본 변형 (variant)
+기본값: 'public'
+
+---
+
+### maxUploadBytes?
+
+> `optional` **maxUploadBytes**: `number`
+
+---
+
+### signingKey?
+
+> `optional` **signingKey**: `string`
+
+---
+
+### ttl?
+
+> `optional` **ttl**: `number`
+
+Upload Intent TTL (초 단위, 기본값: 3600)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckContext.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckContext.md
new file mode 100644
index 000000000..479845601
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckContext.md
@@ -0,0 +1,22 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesReadinessCheckContext"
+---
+
+> **CloudflareImagesReadinessCheckContext** = `object`
+
+Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다.
+
+## Properties
+
+### config
+
+> `readonly` **config**: [`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)
+
+---
+
+### signal?
+
+> `readonly` `optional` **signal**: `AbortSignal`
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckResult.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckResult.md
new file mode 100644
index 000000000..091614a82
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckResult.md
@@ -0,0 +1,22 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareImagesReadinessCheckResult"
+---
+
+> **CloudflareImagesReadinessCheckResult** = `object`
+
+Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다.
+
+## Properties
+
+### details?
+
+> `readonly` `optional` **details**: `Record`\<`string`, `unknown`\>
+
+---
+
+### message?
+
+> `readonly` `optional` **message**: `string`
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareTransformOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareTransformOptions.md
new file mode 100644
index 000000000..293266793
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareTransformOptions.md
@@ -0,0 +1,103 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareTransformOptions"
+---
+
+> **CloudflareTransformOptions** = `object`
+
+Cloudflare 고유 변환 옵션입니다.
+
+## Properties
+
+### blur?
+
+> `optional` **blur**: `number`
+
+블러 (1-1000)
+
+---
+
+### dpr?
+
+> `optional` **dpr**: `number`
+
+Device Pixel Ratio
+
+---
+
+### fit?
+
+> `optional` **fit**: `"scale-down"` \| `"contain"` \| `"cover"` \| `"fill"`
+
+맞춤 방식
+
+- scale-down: 비율 유지하며 지정 크기 내에서 축소
+- contain: 비율 유지하며 지정 크기에 맞춤 (여백 있음)
+- cover: 비율 유지하며 지정 크기 채움 (자름)
+- fill: 비율 무시하고 지정 크기 채움
+
+---
+
+### format?
+
+> `optional` **format**: `"webp"` \| `"avif"` \| `"jpeg"` \| `"png"` \| `"gif"`
+
+출력 형식
+
+---
+
+### grayscale?
+
+> `optional` **grayscale**: `boolean`
+
+그레이스케일 변환
+
+---
+
+### height?
+
+> `optional` **height**: `number`
+
+높이 (px)
+
+---
+
+### invert?
+
+> `optional` **invert**: `boolean`
+
+반전
+
+---
+
+### quality?
+
+> `optional` **quality**: `number`
+
+품질 (1-100)
+
+---
+
+### rotate?
+
+> `optional` **rotate**: `number`
+
+회전 (0-359)
+
+---
+
+### sharpen?
+
+> `optional` **sharpen**: `number`
+
+선명화 (1-10)
+
+---
+
+### width?
+
+> `optional` **width**: `number`
+
+너비 (px)
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareUploadResponse.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareUploadResponse.md
new file mode 100644
index 000000000..6c9de4a7f
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareUploadResponse.md
@@ -0,0 +1,72 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudflareUploadResponse"
+---
+
+> **CloudflareUploadResponse** = `object`
+
+Cloudflare Images 업로드 응답 구조입니다.
+
+## Properties
+
+### errors
+
+> **errors**: `unknown`[]
+
+에러 목록
+
+---
+
+### messages
+
+> **messages**: `unknown`[]
+
+메시지 목록
+
+---
+
+### result
+
+> **result**: `object`
+
+업로드 결과
+
+#### filename
+
+> **filename**: `string`
+
+원본 파일명
+
+#### id
+
+> **id**: `string`
+
+이미지 ID (고유 식별자)
+
+#### requireSignedURLs
+
+> **requireSignedURLs**: `boolean`
+
+서명된 URL 필요 여부
+
+#### uploaded
+
+> **uploaded**: `string`
+
+업로드 시간
+
+#### variants
+
+> **variants**: `string`[]
+
+가능한 변형(variants) URL 목록
+
+---
+
+### success
+
+> **success**: `boolean`
+
+성공 여부
diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/variables/CLOUDFLARE_IMAGES_OPTIONS.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/variables/CLOUDFLARE_IMAGES_OPTIONS.md
new file mode 100644
index 000000000..c7786e444
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/variables/CLOUDFLARE_IMAGES_OPTIONS.md
@@ -0,0 +1,10 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CLOUDFLARE_IMAGES_OPTIONS"
+---
+
+> `const` **CLOUDFLARE_IMAGES_OPTIONS**: _typeof_ `CLOUDFLARE_IMAGES_OPTIONS`
+
+Cloudflare Images 제공자 설정 DI 토큰
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryDiagnosticsProvider.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryDiagnosticsProvider.md
new file mode 100644
index 000000000..0966f8cf6
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryDiagnosticsProvider.md
@@ -0,0 +1,60 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryDiagnosticsProvider"
+---
+
+## Implements
+
+- [`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudinaryDiagnosticsProvider**(`config`, `options?`): `CloudinaryDiagnosticsProvider`
+
+#### Parameters
+
+##### config
+
+`Partial`\<[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)\>
+
+##### options?
+
+[`CloudinaryDiagnosticsOptions`](/api/storage-cloudinary/src/type-aliases/cloudinarydiagnosticsoptions/) = `{}`
+
+#### Returns
+
+`CloudinaryDiagnosticsProvider`
+
+## Properties
+
+### name
+
+> `readonly` **name**: `"storage-cloudinary"` = `"storage-cloudinary"`
+
+#### Implementation of
+
+[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`name`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#name)
+
+## Methods
+
+### getHealth()
+
+> **getHealth**(`signal?`): `Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\>
+
+#### Parameters
+
+##### signal?
+
+`AbortSignal`
+
+#### Returns
+
+`Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\>
+
+#### Implementation of
+
+[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`getHealth`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#gethealth)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryMissingConfigProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryMissingConfigProblem.md
new file mode 100644
index 000000000..3292a6b78
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryMissingConfigProblem.md
@@ -0,0 +1,298 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryMissingConfigProblem"
+---
+
+RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudinaryMissingConfigProblem**(`configKey`, `operation?`): `CloudinaryMissingConfigProblem`
+
+#### Parameters
+
+##### configKey
+
+[`CloudinaryConfigKey`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfigkey/)
+
+##### operation?
+
+`string` = `"configuration"`
+
+#### Returns
+
+`CloudinaryMissingConfigProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryProvider.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryProvider.md
new file mode 100644
index 000000000..536bfbf16
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryProvider.md
@@ -0,0 +1,334 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryProvider"
+---
+
+Cloudinary를 이용해 파일 저장과 이미지 변환 URL 생성을 제공하는 구현체입니다.
+
+## Extends
+
+- [`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/)
+
+## Implements
+
+- [`ImageProvider`](/api/storage-core/src/type-aliases/imageprovider/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudinaryProvider**(`config`): `CloudinaryProvider`
+
+#### Parameters
+
+##### config
+
+[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)
+
+#### Returns
+
+`CloudinaryProvider`
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`constructor`](/api/storage-core/src/classes/basestorageprovider/#constructor)
+
+## Methods
+
+### delete()
+
+> **delete**(`key`): `Promise`\<`void`\>
+
+파일 삭제
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`void`\>
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`delete`](/api/storage-core/src/classes/basestorageprovider/#delete)
+
+---
+
+### exists()
+
+> **exists**(`key`): `Promise`\<`boolean`\>
+
+파일 존재 여부 확인
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`boolean`\>
+
+#### Inherited from
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`exists`](/api/storage-core/src/classes/basestorageprovider/#exists)
+
+---
+
+### get()
+
+> **get**(`key`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\>
+
+파일 다운로드
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`Buffer`\<`ArrayBufferLike`\>\>
+
+파일 버퍼
+
+#### Throws
+
+FileNotFoundProblem - 파일이 존재하지 않을 때
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`get`](/api/storage-core/src/classes/basestorageprovider/#get)
+
+---
+
+### getMetadata()
+
+> **getMetadata**(`key`): `Promise`\<[`ObjectMetadata`](/api/storage-core/src/type-aliases/objectmetadata/)\>
+
+객체 메타데이터 조회
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<[`ObjectMetadata`](/api/storage-core/src/type-aliases/objectmetadata/)\>
+
+객체 메타데이터
+
+#### Throws
+
+FileNotFoundProblem - 파일이 존재하지 않을 때
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getMetadata`](/api/storage-core/src/classes/basestorageprovider/#getmetadata)
+
+---
+
+### getPublicUrl()
+
+> **getPublicUrl**(`key`): `string`
+
+공개 URL 반환
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`string`
+
+공개 액세스 가능한 URL
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getPublicUrl`](/api/storage-core/src/classes/basestorageprovider/#getpublicurl)
+
+---
+
+### getSignedUrl()
+
+> **getSignedUrl**(`key`, `options`): `Promise`\<`string`\>
+
+서명된 URL 반환 (임시 액세스)
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+##### options
+
+[`SignedUrlOptions`](/api/storage-core/src/type-aliases/signedurloptions/)
+
+만료 시간 등 옵션
+
+#### Returns
+
+`Promise`\<`string`\>
+
+서명된 URL
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getSignedUrl`](/api/storage-core/src/classes/basestorageprovider/#getsignedurl)
+
+---
+
+### getStream()
+
+> **getStream**(`key`): `Promise`\<`Readable`\>
+
+파일 스트림 다운로드
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+#### Returns
+
+`Promise`\<`Readable`\>
+
+읽기 가능한 스트림
+
+#### Throws
+
+FileNotFoundProblem - 파일이 존재하지 않을 때
+
+#### Inherited from
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getStream`](/api/storage-core/src/classes/basestorageprovider/#getstream)
+
+---
+
+### getTransformUrl()
+
+> **getTransformUrl**(`key`, `options`): `string`
+
+변환된 이미지 URL 반환
+
+CDN에서 실시간으로 이미지를 변환하고 반환합니다.
+
+#### Parameters
+
+##### key
+
+`string`
+
+원본 이미지 식별자
+
+##### options
+
+[`TransformOptions`](/api/storage-core/src/type-aliases/transformoptions/)
+
+변환 옵션
+
+#### Returns
+
+`string`
+
+변환된 이미지의 공개 URL
+
+#### Implementation of
+
+`ImageProvider.getTransformUrl`
+
+---
+
+### getUploadIntent()
+
+> **getUploadIntent**(`key`, `options?`): `Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\>
+
+클라이언트 직접 업로드를 위한 의도 생성 (선택)
+
+#### Parameters
+
+##### key
+
+`string`
+
+업로드할 파일 식별자
+
+##### options?
+
+###### ttlInSeconds?
+
+`number`
+
+#### Returns
+
+`Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\>
+
+업로드 의도 정보
+
+#### Implementation of
+
+`ImageProvider.getUploadIntent`
+
+---
+
+### put()
+
+> **put**(`key`, `data`, `options?`): `Promise`\<`void`\>
+
+파일 업로드
+
+#### Parameters
+
+##### key
+
+`string`
+
+파일 식별자
+
+##### data
+
+파일 데이터 (Buffer 또는 Readable 스트림)
+
+`Readable` | `Buffer`\<`ArrayBufferLike`\>
+
+##### options?
+
+[`PutOptions`](/api/storage-core/src/type-aliases/putoptions/)
+
+업로드 옵션
+
+#### Returns
+
+`Promise`\<`void`\>
+
+#### Overrides
+
+[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`put`](/api/storage-core/src/classes/basestorageprovider/#put)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryRetryableUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryRetryableUpstreamProblem.md
new file mode 100644
index 000000000..b001a6a4e
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryRetryableUpstreamProblem.md
@@ -0,0 +1,294 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryRetryableUpstreamProblem"
+---
+
+RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudinaryRetryableUpstreamProblem**(`context`): `CloudinaryRetryableUpstreamProblem`
+
+#### Parameters
+
+##### context
+
+[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/)
+
+#### Returns
+
+`CloudinaryRetryableUpstreamProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryTerminalUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryTerminalUpstreamProblem.md
new file mode 100644
index 000000000..9ba093ef7
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryTerminalUpstreamProblem.md
@@ -0,0 +1,294 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryTerminalUpstreamProblem"
+---
+
+RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudinaryTerminalUpstreamProblem**(`context`): `CloudinaryTerminalUpstreamProblem`
+
+#### Parameters
+
+##### context
+
+[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/)
+
+#### Returns
+
+`CloudinaryTerminalUpstreamProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryValidationProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryValidationProblem.md
new file mode 100644
index 000000000..0d2d14cf6
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryValidationProblem.md
@@ -0,0 +1,298 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryValidationProblem"
+---
+
+RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다.
+
+## Extends
+
+- [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Constructors
+
+### Constructor
+
+> **new CloudinaryValidationProblem**(`context`, `detail?`): `CloudinaryValidationProblem`
+
+#### Parameters
+
+##### context
+
+[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/)
+
+##### detail?
+
+`string` = `"Cloudinary storage request validation failed"`
+
+#### Returns
+
+`CloudinaryValidationProblem`
+
+#### Overrides
+
+`Problem.constructor`
+
+## Properties
+
+### category
+
+> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category)
+
+---
+
+### cause?
+
+> `readonly` `optional` **cause**: `Error`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause)
+
+---
+
+### code
+
+> `readonly` **code**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code)
+
+---
+
+### detail?
+
+> `readonly` `optional` **detail**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail)
+
+---
+
+### extensions?
+
+> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions)
+
+---
+
+### instance?
+
+> `readonly` `optional` **instance**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance)
+
+---
+
+### message
+
+> **message**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message)
+
+---
+
+### name
+
+> **name**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name)
+
+---
+
+### stack?
+
+> `optional` **stack**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack)
+
+---
+
+### type
+
+> `readonly` **type**: `string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type)
+
+---
+
+### stackTraceLimit
+
+> `static` **stackTraceLimit**: `number`
+
+The `Error.stackTraceLimit` property specifies the number of stack frames
+collected by a stack trace (whether generated by `new Error().stack` or
+`Error.captureStackTrace(obj)`).
+
+The default value is `10` but may be set to any valid JavaScript number. Changes
+will affect any stack trace captured _after_ the value has been changed.
+
+If set to a non-number value, or set to a negative number, stack traces will
+not capture any frames.
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit)
+
+## Accessors
+
+### status
+
+#### Get Signature
+
+> **get** **status**(): `number`
+
+##### Returns
+
+`number`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status)
+
+---
+
+### title
+
+#### Get Signature
+
+> **get** **title**(): `string`
+
+##### Returns
+
+`string`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title)
+
+## Methods
+
+### toJSON()
+
+> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson)
+
+---
+
+### captureStackTrace()
+
+> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void`
+
+Creates a `.stack` property on `targetObject`, which when accessed returns
+a string representing the location in the code at which
+`Error.captureStackTrace()` was called.
+
+```js
+const myObject = {};
+Error.captureStackTrace(myObject);
+myObject.stack; // Similar to `new Error().stack`
+```
+
+The first line of the trace will be prefixed with
+`${myObject.name}: ${myObject.message}`.
+
+The optional `constructorOpt` argument accepts a function. If given, all frames
+above `constructorOpt`, including `constructorOpt`, will be omitted from the
+generated stack trace.
+
+The `constructorOpt` argument is useful for hiding implementation
+details of error generation from the user. For instance:
+
+```js
+function a() {
+ b();
+}
+
+function b() {
+ c();
+}
+
+function c() {
+ // Create an error without stack trace to avoid calculating the stack trace twice.
+ const { stackTraceLimit } = Error;
+ Error.stackTraceLimit = 0;
+ const error = new Error();
+ Error.stackTraceLimit = stackTraceLimit;
+
+ // Capture the stack trace above function b
+ Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
+ throw error;
+}
+
+a();
+```
+
+#### Parameters
+
+##### targetObject
+
+`object`
+
+##### constructorOpt?
+
+`Function`
+
+#### Returns
+
+`void`
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace)
+
+---
+
+### prepareStackTrace()
+
+> `static` **prepareStackTrace**(`err`, `stackTraces`): `any`
+
+#### Parameters
+
+##### err
+
+`Error`
+
+##### stackTraces
+
+`CallSite`[]
+
+#### Returns
+
+`any`
+
+#### See
+
+https://v8.dev/docs/stack-trace-api#customizing-stack-traces
+
+#### Inherited from
+
+[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/getCloudinaryErrorMessage.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/getCloudinaryErrorMessage.md
new file mode 100644
index 000000000..7c3502bf6
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/getCloudinaryErrorMessage.md
@@ -0,0 +1,22 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "getCloudinaryErrorMessage"
+---
+
+> **getCloudinaryErrorMessage**(`error`, `fallback`): `string`
+
+## Parameters
+
+### error
+
+`unknown`
+
+### fallback
+
+`string`
+
+## Returns
+
+`string`
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/isRetryableCloudinaryStorageError.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/isRetryableCloudinaryStorageError.md
new file mode 100644
index 000000000..f93d934d1
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/isRetryableCloudinaryStorageError.md
@@ -0,0 +1,22 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "isRetryableCloudinaryStorageError"
+---
+
+> **isRetryableCloudinaryStorageError**(`error`, `knownContext?`): `boolean`
+
+## Parameters
+
+### error
+
+`unknown`
+
+### knownContext?
+
+[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/)
+
+## Returns
+
+`boolean`
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/normalizeCloudinaryStorageError.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/normalizeCloudinaryStorageError.md
new file mode 100644
index 000000000..127a7cd29
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/normalizeCloudinaryStorageError.md
@@ -0,0 +1,36 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "normalizeCloudinaryStorageError"
+---
+
+> **normalizeCloudinaryStorageError**(`error`, `options`): [`Problem`](/api/problems-core/src/classes/problem/)
+
+## Parameters
+
+### error
+
+`unknown`
+
+### options
+
+#### key?
+
+`string`
+
+#### operation
+
+`string`
+
+#### status?
+
+`number`
+
+#### upstreamCode?
+
+`string`
+
+## Returns
+
+[`Problem`](/api/problems-core/src/classes/problem/)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/validateCloudinaryConfig.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/validateCloudinaryConfig.md
new file mode 100644
index 000000000..3d63edf25
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/validateCloudinaryConfig.md
@@ -0,0 +1,18 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "validateCloudinaryConfig"
+---
+
+> **validateCloudinaryConfig**(`config`): [`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)
+
+## Parameters
+
+### config
+
+`Partial`\<[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)\>
+
+## Returns
+
+[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfig.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfig.md
new file mode 100644
index 000000000..dc2dccde0
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfig.md
@@ -0,0 +1,56 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryConfig"
+---
+
+> **CloudinaryConfig** = `object`
+
+Cloudinary 제공자 설정입니다.
+
+## Properties
+
+### apiKey
+
+> **apiKey**: `string`
+
+Cloudinary API Key
+
+---
+
+### apiSecret
+
+> **apiSecret**: `string`
+
+Cloudinary API Secret
+
+---
+
+### cloudName
+
+> **cloudName**: `string`
+
+Cloudinary 클라우드 이름
+
+---
+
+### secure?
+
+> `optional` **secure**: `boolean`
+
+HTTPS 사용 여부 (기본값: true)
+
+---
+
+### ttl?
+
+> `optional` **ttl**: `number`
+
+Upload Intent TTL (초 단위, 기본값: 3600)
+
+---
+
+### uploadBaseUrl?
+
+> `optional` **uploadBaseUrl**: `string`
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfigKey.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfigKey.md
new file mode 100644
index 000000000..be0f31249
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfigKey.md
@@ -0,0 +1,10 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryConfigKey"
+---
+
+> **CloudinaryConfigKey** = `"apiKey"` \| `"apiSecret"` \| `"cloudName"`
+
+Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다.
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryDiagnosticsOptions.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryDiagnosticsOptions.md
new file mode 100644
index 000000000..21d113122
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryDiagnosticsOptions.md
@@ -0,0 +1,26 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryDiagnosticsOptions"
+---
+
+> **CloudinaryDiagnosticsOptions** = `object`
+
+Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다.
+
+## Properties
+
+### readinessCheck()?
+
+> `readonly` `optional` **readinessCheck**: (`context`) => `Promise`\<[`CloudinaryReadinessCheckResult`](/api/storage-cloudinary/src/type-aliases/cloudinaryreadinesscheckresult/) \| `void`\>
+
+#### Parameters
+
+##### context
+
+[`CloudinaryReadinessCheckContext`](/api/storage-cloudinary/src/type-aliases/cloudinaryreadinesscheckcontext/)
+
+#### Returns
+
+`Promise`\<[`CloudinaryReadinessCheckResult`](/api/storage-cloudinary/src/type-aliases/cloudinaryreadinesscheckresult/) \| `void`\>
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckContext.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckContext.md
new file mode 100644
index 000000000..22a9559a1
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckContext.md
@@ -0,0 +1,22 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryReadinessCheckContext"
+---
+
+> **CloudinaryReadinessCheckContext** = `object`
+
+Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다.
+
+## Properties
+
+### config
+
+> `readonly` **config**: [`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)
+
+---
+
+### signal?
+
+> `readonly` `optional` **signal**: `AbortSignal`
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckResult.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckResult.md
new file mode 100644
index 000000000..084739845
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckResult.md
@@ -0,0 +1,22 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryReadinessCheckResult"
+---
+
+> **CloudinaryReadinessCheckResult** = `object`
+
+Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다.
+
+## Properties
+
+### details?
+
+> `readonly` `optional` **details**: `Record`\<`string`, `unknown`\>
+
+---
+
+### message?
+
+> `readonly` `optional` **message**: `string`
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryStorageErrorContext.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryStorageErrorContext.md
new file mode 100644
index 000000000..1a33bf628
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryStorageErrorContext.md
@@ -0,0 +1,46 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryStorageErrorContext"
+---
+
+> **CloudinaryStorageErrorContext** = `object`
+
+Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다.
+
+## Properties
+
+### key?
+
+> `readonly` `optional` **key**: `string`
+
+---
+
+### operation
+
+> `readonly` **operation**: `string`
+
+---
+
+### provider
+
+> `readonly` **provider**: `"cloudinary"`
+
+---
+
+### retryable?
+
+> `readonly` `optional` **retryable**: `boolean`
+
+---
+
+### status?
+
+> `readonly` `optional` **status**: `number`
+
+---
+
+### upstreamCode?
+
+> `readonly` `optional` **upstreamCode**: `string`
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryTransformOptions.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryTransformOptions.md
new file mode 100644
index 000000000..dfbbfbdf8
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryTransformOptions.md
@@ -0,0 +1,46 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryTransformOptions"
+---
+
+> **CloudinaryTransformOptions** = `object`
+
+Cloudinary 변환 파라미터 타입입니다.
+
+## Properties
+
+### crop?
+
+> `optional` **crop**: `"scale"` \| `"fit"` \| `"fill"` \| `"limit"` \| `"pad"` \| `"crop"` \| `"thumb"`
+
+---
+
+### dpr?
+
+> `optional` **dpr**: `number`
+
+---
+
+### format?
+
+> `optional` **format**: `string`
+
+---
+
+### height?
+
+> `optional` **height**: `number`
+
+---
+
+### quality?
+
+> `optional` **quality**: `number`
+
+---
+
+### width?
+
+> `optional` **width**: `number`
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryUploadOptions.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryUploadOptions.md
new file mode 100644
index 000000000..cca7e5544
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryUploadOptions.md
@@ -0,0 +1,58 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CloudinaryUploadOptions"
+---
+
+> **CloudinaryUploadOptions** = `object`
+
+Cloudinary 업로드에 사용할 확장 옵션입니다.
+
+## Properties
+
+### context?
+
+> `optional` **context**: `Record`\<`string`, `string`\>
+
+컨텍스트 메타데이터 (key-value 쌍)
+
+---
+
+### eager?
+
+> `optional` **eager**: `unknown`[]
+
+업로드 시 적용할 변환 (eager transformations)
+
+---
+
+### folder?
+
+> `optional` **folder**: `string`
+
+업로드할 폴더 경로
+
+---
+
+### publicId?
+
+> `optional` **publicId**: `string`
+
+사용자 정의 public ID (key)
+
+---
+
+### resourceType?
+
+> `optional` **resourceType**: `"image"` \| `"video"` \| `"raw"`
+
+리소스 타입
+
+---
+
+### tags?
+
+> `optional` **tags**: `string`[]
+
+태그 목록
diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/variables/CLOUDINARY_CONFIG.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/variables/CLOUDINARY_CONFIG.md
new file mode 100644
index 000000000..52fcfa1ff
--- /dev/null
+++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/variables/CLOUDINARY_CONFIG.md
@@ -0,0 +1,10 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "CLOUDINARY_CONFIG"
+---
+
+> `const` **CLOUDINARY_CONFIG**: _typeof_ `CLOUDINARY_CONFIG`
+
+Cloudinary 제공자 설정 DI 토큰
diff --git a/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md b/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md
index 538de3da9..e7d6f3d3c 100644
--- a/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md
+++ b/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md
@@ -7,6 +7,11 @@ title: "BaseStorageProvider"
스토리지 제공자 구현을 위한 기본 추상 클래스입니다.
+## Extended by
+
+- [`CloudflareImagesProvider`](/api/storage-cloudflare/src/classes/cloudflareimagesprovider/)
+- [`CloudinaryProvider`](/api/storage-cloudinary/src/classes/cloudinaryprovider/)
+
## Implements
- [`StorageProvider`](/api/storage-core/src/type-aliases/storageprovider/)
@@ -45,7 +50,7 @@ title: "BaseStorageProvider"
`StorageProvider.delete`
-***
+---
### exists()
@@ -69,7 +74,7 @@ title: "BaseStorageProvider"
`StorageProvider.exists`
-***
+---
### get()
@@ -99,7 +104,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때
`StorageProvider.get`
-***
+---
### getMetadata()
@@ -129,7 +134,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때
`StorageProvider.getMetadata`
-***
+---
### getPublicUrl()
@@ -155,7 +160,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때
`StorageProvider.getPublicUrl`
-***
+---
### getSignedUrl()
@@ -187,7 +192,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때
`StorageProvider.getSignedUrl`
-***
+---
### getStream()
@@ -217,7 +222,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때
`StorageProvider.getStream`
-***
+---
### put()
diff --git a/packages/docs/src/content/docs/en/reference/extension-matrix.md b/packages/docs/src/content/docs/en/reference/extension-matrix.md
index 0d6f32287..62acb0397 100644
--- a/packages/docs/src/content/docs/en/reference/extension-matrix.md
+++ b/packages/docs/src/content/docs/en/reference/extension-matrix.md
@@ -38,8 +38,8 @@ Runtime columns: Node covers long-running server and CLI use, Lambda covers serv
| `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | - | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests |
| `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🔴 alpha/WIP | has package tests |
| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests |
-| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs | 🔴 alpha/WIP | has package tests |
-| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs
storage conformance
diagnostics
optional live smoke | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry
storage conformance
diagnostics
optional live smoke | 🟡 beta | has package tests |
| `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests |
| `@croco/tasks-qstash` | Tasks | QStash task runner | yes | yes | - | - | UPSTASH_QSTASH_TOKEN
UPSTASH_QSTASH_DESTINATION_URL | - | task publish
delay override
custom headers
deduplication id
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests |
| `@croco/triggers-qstash` | Triggers | QStash scheduler and webhook handler | yes | yes | - | - | QSTASH_TOKEN
public webhook URL | - | schedule publish
webhook verification
trigger dispatch
shared conformance
redacted schedule diagnostics
diagnostic-coded webhook failures | 🔴 alpha/WIP | has package tests |
diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md
index 4fcd52172..1c2778680 100644
--- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md
+++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md
@@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m
> Generated by `pnpm problem-registry:write`. Do not edit this file by hand.
-This cookbook documents 367 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`.
+This cookbook documents 375 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`.
## Index
@@ -305,7 +305,15 @@ This cookbook documents 367 public Croco Problem codes. The deterministic JSON r
| [`STORAGE_R2_OBJECT_TOO_LARGE`](#storage-r2-object-too-large) | InternalServerError | 500 | conditional | operator-only | 1 |
| [`STORAGE_R2_READINESS_FAILED`](#storage-r2-readiness-failed) | InternalServerError | 500 | conditional | operator-only | 1 |
| [`STORAGE_UPLOAD_FAILED`](#storage-upload-failed) | InternalServerError | 500 | conditional | operator-only | 1 |
+| [`storage-cloudflare/missing-config`](#storage-cloudflare-missing-config) | InternalServerError | 500 | conditional | operator-only | 1 |
+| [`storage-cloudflare/retryable-upstream`](#storage-cloudflare-retryable-upstream) | InternalServerError | 500 | conditional | operator-only | 1 |
+| [`storage-cloudflare/terminal-upstream`](#storage-cloudflare-terminal-upstream) | InternalServerError | 500 | conditional | operator-only | 1 |
+| [`storage-cloudflare/validation-failed`](#storage-cloudflare-validation-failed) | ValidationError | 422 | not-retryable | public | 1 |
| [`storage-cloudinary/invalid-upload-intent-ttl`](#storage-cloudinary-invalid-upload-intent-ttl) | BadRequest | 400 | not-retryable | public | 1 |
+| [`storage-cloudinary/missing-config`](#storage-cloudinary-missing-config) | InternalServerError | 500 | conditional | operator-only | 1 |
+| [`storage-cloudinary/retryable-upstream`](#storage-cloudinary-retryable-upstream) | InternalServerError | 500 | conditional | operator-only | 1 |
+| [`storage-cloudinary/terminal-upstream`](#storage-cloudinary-terminal-upstream) | InternalServerError | 500 | conditional | operator-only | 1 |
+| [`storage-cloudinary/validation-failed`](#storage-cloudinary-validation-failed) | ValidationError | 422 | not-retryable | public | 1 |
| [`storage/invalid-upload-intent-ttl`](#storage-invalid-upload-intent-ttl) | BadRequest | 400 | not-retryable | public | 1 |
| [`STRATEGY_UNAVAILABLE`](#strategy-unavailable) | InternalServerError | 500 | conditional | operator-only | 1 |
| [`STRUCTURED_OUTPUT_ERROR`](#structured-output-error) | InternalServerError | 500 | conditional | operator-only | 1 |
@@ -1552,7 +1560,7 @@ Sources:
Sources:
-- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:44:13` (problem-factory)
+- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:48:13` (problem-factory)
@@ -1569,7 +1577,7 @@ Sources:
Sources:
-- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:206:13` (problem-factory)
+- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:256:13` (problem-factory)
@@ -1586,7 +1594,7 @@ Sources:
Sources:
-- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:267:13` (problem-factory)
+- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:331:13` (problem-factory)
@@ -5345,6 +5353,74 @@ Sources:
- `packages/storage-core/src/libs/problems/UploadFailedProblem.ts:11:5` (problem-constructor)
+
+
+## `storage-cloudflare/missing-config`
+
+- Category: `InternalServerError`
+- HTTP status: `500` Internal Server Error
+- Retryability: `conditional`
+- Redaction policy: `operator-only`
+- Cause: Croco or an upstream dependency failed after accepting the request.
+- User action: Retry later only when the operation is idempotent or the caller owns retry safety.
+- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary.
+- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:37:5` (problem-constructor)
+
+
+
+## `storage-cloudflare/retryable-upstream`
+
+- Category: `InternalServerError`
+- HTTP status: `500` Internal Server Error
+- Retryability: `conditional`
+- Redaction policy: `operator-only`
+- Cause: Croco or an upstream dependency failed after accepting the request.
+- User action: Retry later only when the operation is idempotent or the caller owns retry safety.
+- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary.
+- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:70:5` (problem-constructor)
+
+
+
+## `storage-cloudflare/terminal-upstream`
+
+- Category: `InternalServerError`
+- HTTP status: `500` Internal Server Error
+- Retryability: `conditional`
+- Redaction policy: `operator-only`
+- Cause: Croco or an upstream dependency failed after accepting the request.
+- User action: Retry later only when the operation is idempotent or the caller owns retry safety.
+- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary.
+- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:86:5` (problem-constructor)
+
+
+
+## `storage-cloudflare/validation-failed`
+
+- Category: `ValidationError`
+- HTTP status: `422` Validation Error
+- Retryability: `not-retryable`
+- Redaction policy: `public`
+- Cause: The request or generated contract failed schema or semantic validation.
+- User action: Fix the invalid fields and retry with schema-conformant input.
+- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata.
+- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:57:5` (problem-constructor)
+
## `storage-cloudinary/invalid-upload-intent-ttl`
@@ -5360,7 +5436,75 @@ Sources:
Sources:
-- `packages/storage-cloudinary/src/libs/CloudinaryProvider.ts:396:13` (problem-factory)
+- `packages/storage-cloudinary/src/libs/CloudinaryProvider.ts:331:13` (problem-factory)
+
+
+
+## `storage-cloudinary/missing-config`
+
+- Category: `InternalServerError`
+- HTTP status: `500` Internal Server Error
+- Retryability: `conditional`
+- Redaction policy: `operator-only`
+- Cause: Croco or an upstream dependency failed after accepting the request.
+- User action: Retry later only when the operation is idempotent or the caller owns retry safety.
+- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary.
+- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:51:5` (problem-constructor)
+
+
+
+## `storage-cloudinary/retryable-upstream`
+
+- Category: `InternalServerError`
+- HTTP status: `500` Internal Server Error
+- Retryability: `conditional`
+- Redaction policy: `operator-only`
+- Cause: Croco or an upstream dependency failed after accepting the request.
+- User action: Retry later only when the operation is idempotent or the caller owns retry safety.
+- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary.
+- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:84:5` (problem-constructor)
+
+
+
+## `storage-cloudinary/terminal-upstream`
+
+- Category: `InternalServerError`
+- HTTP status: `500` Internal Server Error
+- Retryability: `conditional`
+- Redaction policy: `operator-only`
+- Cause: Croco or an upstream dependency failed after accepting the request.
+- User action: Retry later only when the operation is idempotent or the caller owns retry safety.
+- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary.
+- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:100:5` (problem-constructor)
+
+
+
+## `storage-cloudinary/validation-failed`
+
+- Category: `ValidationError`
+- HTTP status: `422` Validation Error
+- Retryability: `not-retryable`
+- Redaction policy: `public`
+- Cause: The request or generated contract failed schema or semantic validation.
+- User action: Fix the invalid fields and retry with schema-conformant input.
+- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata.
+- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:71:5` (problem-constructor)
@@ -5377,7 +5521,7 @@ Sources:
Sources:
-- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:233:13` (problem-factory)
+- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:283:13` (problem-factory)
diff --git a/packages/docs/src/content/docs/en/reference/provider-maturity.md b/packages/docs/src/content/docs/en/reference/provider-maturity.md
index 465d54941..753decb0b 100644
--- a/packages/docs/src/content/docs/en/reference/provider-maturity.md
+++ b/packages/docs/src/content/docs/en/reference/provider-maturity.md
@@ -61,10 +61,11 @@ The current `@croco/storage-core` `StorageProvider` contract does not expose `li
The first consumers are:
-| Package | Harness evidence | Promotion result |
-| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `@croco/storage-r2` | Uses the storage conformance suite with a stateful mocked S3/R2 backend, requires content type plus custom metadata preservation, exposes safe diagnostics/readiness, and documents an env-gated live R2 smoke. | Remains beta. It passes default conformance and diagnostics locally, but production-ready still requires recorded optional live R2 smoke evidence with real credentials. |
-| `@croco/storage-cloudflare` | Uses the storage conformance suite with an in-memory Cloudflare Images fetch backend. Metadata preservation is marked unsupported because the current provider metadata contract returns size and upload time only. | Remains alpha. It has shared contract coverage, but metadata limits, diagnostics, and live smoke documentation still block beta/production promotion. |
+| Package | Harness evidence | Promotion result |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `@croco/storage-r2` | Uses the storage conformance suite with a stateful mocked S3/R2 backend, requires content type plus custom metadata preservation, exposes safe diagnostics/readiness, and documents an env-gated live R2 smoke. | Remains beta. It passes default conformance and diagnostics locally, but production-ready still requires recorded optional live R2 smoke evidence with real credentials. |
+| `@croco/storage-cloudflare` | Uses the storage conformance suite with an in-memory Cloudflare Images fetch backend, exposes safe diagnostics/readiness, and includes an opt-in live-smoke gate. Metadata preservation is marked unsupported because the current provider metadata contract returns size and upload time only. | Remains alpha. It has shared contract coverage and readiness diagnostics, but Cloudflare Images metadata limits still block beta/production promotion. |
+| `@croco/storage-cloudinary` | Uses the storage conformance suite with a mocked Cloudinary SDK/fetch backend, preserves custom metadata, reports content type as Cloudinary format metadata, exposes safe diagnostics/readiness, and includes an opt-in live-smoke gate. | Promoted to beta. It has default conformance and diagnostics evidence, but production-ready still requires recorded real-backend live Cloudinary smoke evidence. |
### Billing provider conformance
diff --git a/packages/docs/tsconfig.typedoc.json b/packages/docs/tsconfig.typedoc.json
index 9da701c7f..0cdbcacac 100644
--- a/packages/docs/tsconfig.typedoc.json
+++ b/packages/docs/tsconfig.typedoc.json
@@ -59,6 +59,8 @@
"../retry-core/src/index.ts",
"../rpc-codegen/src/index.ts",
"../search-core/src/index.ts",
+ "../storage-cloudflare/src/index.ts",
+ "../storage-cloudinary/src/index.ts",
"../storage-core/src/index.ts",
"../telemetry-api/src/index.ts",
"../telemetry-sdk-node/src/index.ts",
diff --git a/packages/storage-cloudflare/README.md b/packages/storage-cloudflare/README.md
index 2c648cd65..fcaa0fef8 100644
--- a/packages/storage-cloudflare/README.md
+++ b/packages/storage-cloudflare/README.md
@@ -47,18 +47,63 @@ const transformUrl = provider.getTransformUrl("avatars/user-1.jpg", {
## API 레퍼런스
-| API | 설명 |
-| ---------------------------------------------------- | ----------------------------------------------------------------------- |
-| `CloudflareImagesProvider` | 이미지 업로드, 다운로드, 삭제, 메타데이터 조회를 처리합니다. |
-| `getTransformUrl()` | width, height, fit, format, quality를 Cloudflare 파라미터로 변환합니다. |
-| `getUploadIntent()` | 클라이언트 직접 업로드용 URL과 만료 시간을 돌려줍니다. |
-| `CLOUDFLARE_IMAGES_OPTIONS` | DI 등록용 토큰입니다. |
-| `CloudflareImagesOptions` | 제공자 설정 타입입니다. |
-| `CloudflareUploadResponse`, `CloudflareImageDetails` | Cloudflare 응답 구조 타입입니다. |
-| `CloudflareTransformOptions` | Cloudflare 고유 변환 옵션 타입입니다. |
+| API | 설명 |
+| ---------------------------------------------------- | ----------------------------------------------------------------------------- |
+| `CloudflareImagesProvider` | 이미지 업로드, 다운로드, 삭제, 메타데이터 조회를 처리합니다. |
+| `CloudflareImagesDiagnosticsProvider` | 안전한 설정 상태와 선택적 readiness check 결과를 `HealthStatus`로 노출합니다. |
+| `getTransformUrl()` | width, height, fit, format, quality를 Cloudflare 파라미터로 변환합니다. |
+| `getUploadIntent()` | 클라이언트 직접 업로드용 URL과 만료 시간을 돌려줍니다. |
+| `CLOUDFLARE_IMAGES_OPTIONS` | DI 등록용 토큰입니다. |
+| `validateCloudflareImagesOptions()` | 필수 설정과 양수 정수 옵션을 검증합니다. |
+| `normalizeCloudflareImagesError()` | Cloudflare/fetch 실패를 provider 전용 `Problem`으로 정규화합니다. |
+| `CloudflareImagesOptions` | 제공자 설정 타입입니다. |
+| `CloudflareUploadResponse`, `CloudflareImageDetails` | Cloudflare 응답 구조 타입입니다. |
+| `CloudflareTransformOptions` | Cloudflare 고유 변환 옵션 타입입니다. |
+
+## 진단과 readiness
+
+```typescript
+import { CloudflareImagesDiagnosticsProvider } from "@croco/storage-cloudflare";
+
+const diagnostics = new CloudflareImagesDiagnosticsProvider({
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
+ accountHash: process.env.CLOUDFLARE_ACCOUNT_HASH,
+});
+
+const health = await diagnostics.getHealth();
+```
+
+- 필수 설정이 빠지면 `unhealthy` 상태와 `storage-cloudflare/missing-config` 코드가 반환됩니다.
+- `readinessCheck`를 넘기지 않으면 외부 API를 호출하지 않고 설정 존재 여부만 `healthy`로 보고합니다.
+- `readinessCheck`가 실패하면 `degraded` 상태와 정규화된 provider Problem 코드가 반환됩니다.
+- 진단 detail은 토큰, secret, authorization 값을 redaction 처리합니다.
+
+## 실패 코드
+
+| 코드 | 의미 |
+| --------------------------------------- | ------------------------------------------------------------- |
+| `storage-cloudflare/missing-config` | `accountId`, `apiToken`, `accountHash` 중 하나가 없음 |
+| `storage-cloudflare/validation-failed` | 4xx 응답, 잘못된 TTL, 서명 키 누락 등 복구 불가능한 입력 실패 |
+| `storage-cloudflare/retryable-upstream` | 408, 425, 429, 5xx 또는 네트워크 계열 재시도 가능 실패 |
+| `storage-cloudflare/terminal-upstream` | 재시도 대상으로 분류되지 않는 Cloudflare/fetch 실패 |
+
+## 선택적 live smoke
+
+기본 테스트는 실제 Cloudflare 자격 증명을 요구하지 않습니다. 실제 backend readiness를 확인하려면 아래 환경 변수를 모두 설정한 뒤 live smoke를 opt-in 합니다.
+
+```bash
+CROCO_LIVE_CLOUDFLARE_IMAGES=1 \
+CLOUDFLARE_ACCOUNT_ID=... \
+CLOUDFLARE_API_TOKEN=... \
+CLOUDFLARE_ACCOUNT_HASH=... \
+pnpm --filter @croco/storage-cloudflare test -- CloudflareImagesLiveSmoke
+```
## 동작 메모
- Cloudflare Images는 이미지 전용 서비스입니다.
+- `StorageProvider`의 `list()` 계약은 아직 존재하지 않으므로 provider도 목록 조회를 제공하지 않습니다.
+- `put()`의 `contentType`은 업로드 파일 MIME으로만 전달됩니다. `getMetadata()`는 현재 size와 upload time만 반환하며, content type과 custom metadata 보존은 지원하지 않습니다.
- 서명 URL은 `signingKey`가 없으면 생성할 수 없습니다.
- `inside`는 `scale-down`, `outside`는 `cover`로 매핑합니다.
diff --git a/packages/storage-cloudflare/package.json b/packages/storage-cloudflare/package.json
index f19220a5f..b9e0b75aa 100644
--- a/packages/storage-cloudflare/package.json
+++ b/packages/storage-cloudflare/package.json
@@ -32,6 +32,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@croco/diagnostics-core": "workspace:*",
"@croco/framework-context": "workspace:*",
"@croco/problems-core": "workspace:*",
"@croco/storage-core": "workspace:*"
diff --git a/packages/storage-cloudflare/src/index.ts b/packages/storage-cloudflare/src/index.ts
index 3e6375ffd..9efeadd5a 100644
--- a/packages/storage-cloudflare/src/index.ts
+++ b/packages/storage-cloudflare/src/index.ts
@@ -2,6 +2,16 @@
* Cloudflare Images 기반 스토리지 및 이미지 변환 제공자 구현체입니다.
*/
export { CloudflareImagesProvider } from "./libs/CloudflareImagesProvider";
+export {
+ CloudflareImagesDiagnosticsProvider,
+ CloudflareImagesMissingConfigProblem,
+ CloudflareImagesRetryableUpstreamProblem,
+ CloudflareImagesTerminalUpstreamProblem,
+ CloudflareImagesValidationProblem,
+ createCloudflareImagesResponseProblem,
+ normalizeCloudflareImagesError,
+ validateCloudflareImagesOptions,
+} from "./libs/CloudflareImagesDiagnosticsProvider";
/**
* Cloudflare Images 옵션을 DI 컨테이너에 등록할 때 사용하는 토큰입니다.
@@ -11,6 +21,13 @@ export { CLOUDFLARE_IMAGES_OPTIONS } from "./libs/tokens";
/**
* Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다.
*/
+export type {
+ CloudflareImagesConfigKey,
+ CloudflareImagesDiagnosticsOptions,
+ CloudflareImagesErrorContext,
+ CloudflareImagesReadinessCheckContext,
+ CloudflareImagesReadinessCheckResult,
+} from "./libs/CloudflareImagesDiagnosticsProvider";
export type {
CloudflareImageDetails,
CloudflareImagesOptions,
diff --git a/packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts b/packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts
new file mode 100644
index 000000000..0fcd9c9ed
--- /dev/null
+++ b/packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts
@@ -0,0 +1,401 @@
+import type { DiagnosticsProvider, HealthStatus } from "@croco/diagnostics-core";
+import { Problem, ProblemCategory } from "@croco/problems-core";
+import type { CloudflareImagesOptions } from "./types";
+
+export type CloudflareImagesErrorContext = {
+ readonly provider: "cloudflare-images";
+ readonly operation: string;
+ readonly key?: string;
+ readonly status?: number;
+ readonly upstreamCode?: string;
+ readonly retryable?: boolean;
+};
+
+export type CloudflareImagesReadinessCheckContext = {
+ readonly config: CloudflareImagesOptions;
+ readonly signal?: AbortSignal;
+};
+
+export type CloudflareImagesReadinessCheckResult = {
+ readonly message?: string;
+ readonly details?: Record;
+};
+
+export type CloudflareImagesDiagnosticsOptions = {
+ readonly readinessCheck?: (
+ context: CloudflareImagesReadinessCheckContext,
+ ) => Promise;
+};
+
+export type CloudflareImagesConfigKey = "accountHash" | "accountId" | "apiToken";
+
+/**
+ * Problem raised when Cloudflare Images storage is used without a required configuration value.
+ */
+export class CloudflareImagesMissingConfigProblem extends Problem {
+ constructor(configKey: CloudflareImagesConfigKey, operation = "configuration") {
+ super(
+ "storage-cloudflare/missing-config",
+ ProblemCategory.InternalServerError,
+ `Cloudflare Images configuration is missing required value '${configKey}'`,
+ {
+ extensions: {
+ provider: "cloudflare-images",
+ operation,
+ configKey,
+ },
+ },
+ );
+ }
+}
+
+export class CloudflareImagesValidationProblem extends Problem {
+ constructor(
+ context: CloudflareImagesErrorContext,
+ detail = "Cloudflare Images request validation failed",
+ ) {
+ super(
+ "storage-cloudflare/validation-failed",
+ ProblemCategory.ValidationError,
+ `${detail} during ${context.operation}`,
+ {
+ extensions: context,
+ },
+ );
+ }
+}
+
+export class CloudflareImagesRetryableUpstreamProblem extends Problem {
+ constructor(context: CloudflareImagesErrorContext) {
+ super(
+ "storage-cloudflare/retryable-upstream",
+ ProblemCategory.InternalServerError,
+ `Cloudflare Images upstream request failed retryably during ${context.operation}`,
+ {
+ extensions: {
+ ...context,
+ retryable: true,
+ },
+ },
+ );
+ }
+}
+
+export class CloudflareImagesTerminalUpstreamProblem extends Problem {
+ constructor(context: CloudflareImagesErrorContext) {
+ super(
+ "storage-cloudflare/terminal-upstream",
+ ProblemCategory.InternalServerError,
+ `Cloudflare Images upstream request failed terminally during ${context.operation}`,
+ {
+ extensions: {
+ ...context,
+ retryable: false,
+ },
+ },
+ );
+ }
+}
+
+export class CloudflareImagesDiagnosticsProvider implements DiagnosticsProvider {
+ readonly name = "storage-cloudflare";
+
+ constructor(
+ private readonly config: Partial,
+ private readonly options: CloudflareImagesDiagnosticsOptions = {},
+ ) {}
+
+ async getHealth(signal?: AbortSignal): Promise {
+ const baseDetails = this.createSafeConfigDetails();
+ let validConfig: CloudflareImagesOptions;
+
+ try {
+ validConfig = validateCloudflareImagesOptions(this.config);
+ } catch (error) {
+ const problem =
+ error instanceof Problem
+ ? error
+ : normalizeCloudflareImagesError(error, { operation: "configuration" });
+
+ return {
+ status: "unhealthy",
+ component: this.name,
+ message: problem.detail,
+ details: {
+ ...baseDetails,
+ liveCheck: "not_started",
+ problemCode: problem.code,
+ problemStatus: problem.status,
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ }
+
+ if (!this.options.readinessCheck) {
+ return {
+ status: "healthy",
+ component: this.name,
+ message:
+ "Cloudflare Images configuration is present; live upstream readiness check is not configured",
+ details: {
+ ...baseDetails,
+ liveCheck: "not_configured",
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ }
+
+ try {
+ const result = await this.options.readinessCheck({ config: validConfig, signal });
+
+ return {
+ status: "healthy",
+ component: this.name,
+ message: result?.message ?? "Cloudflare Images readiness check passed",
+ details: {
+ ...baseDetails,
+ liveCheck: "passed",
+ ...(result?.details && { readiness: sanitizeDiagnosticValue(result.details) }),
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ } catch (error) {
+ const problem = normalizeCloudflareImagesError(error, { operation: "readiness" });
+
+ return {
+ status: "degraded",
+ component: this.name,
+ message: problem.detail,
+ details: {
+ ...baseDetails,
+ liveCheck: "failed",
+ problemCode: problem.code,
+ problemStatus: problem.status,
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ }
+ }
+
+ private createSafeConfigDetails(): Record {
+ return {
+ provider: "cloudflare-images",
+ hasAccountId: isNonEmptyString(this.config.accountId),
+ hasApiToken: isNonEmptyString(this.config.apiToken),
+ hasAccountHash: isNonEmptyString(this.config.accountHash),
+ hasSigningKey: isNonEmptyString(this.config.signingKey),
+ hasCustomDomain: isNonEmptyString(this.config.customDomain),
+ defaultVariant: this.config.defaultVariant ?? "public",
+ metadataSupport: {
+ contentType: "unsupported",
+ customMetadata: "unsupported",
+ },
+ };
+ }
+}
+
+export function validateCloudflareImagesOptions(
+ config: Partial,
+): CloudflareImagesOptions {
+ if (!isNonEmptyString(config.accountId)) {
+ throw new CloudflareImagesMissingConfigProblem("accountId");
+ }
+
+ if (!isNonEmptyString(config.apiToken)) {
+ throw new CloudflareImagesMissingConfigProblem("apiToken");
+ }
+
+ if (!isNonEmptyString(config.accountHash)) {
+ throw new CloudflareImagesMissingConfigProblem("accountHash");
+ }
+
+ validatePositiveInteger(config.ttl, "ttl");
+ validatePositiveInteger(config.maxUploadBytes, "maxUploadBytes");
+
+ return config as CloudflareImagesOptions;
+}
+
+export function normalizeCloudflareImagesError(
+ error: unknown,
+ options: {
+ readonly operation: string;
+ readonly key?: string;
+ readonly status?: number;
+ readonly upstreamCode?: string;
+ },
+): Problem {
+ if (error instanceof Problem) {
+ return error;
+ }
+
+ const context = createCloudflareImagesErrorContext(error, options);
+
+ if (isValidationError(context)) {
+ return new CloudflareImagesValidationProblem(context);
+ }
+
+ if (isRetryableUpstreamError(context)) {
+ return new CloudflareImagesRetryableUpstreamProblem(context);
+ }
+
+ return new CloudflareImagesTerminalUpstreamProblem(context);
+}
+
+export function createCloudflareImagesResponseProblem(options: {
+ readonly operation: string;
+ readonly key?: string;
+ readonly status?: number;
+ readonly upstreamCode?: string;
+ readonly detail?: string;
+}): Problem {
+ const context: CloudflareImagesErrorContext = {
+ provider: "cloudflare-images",
+ operation: options.operation,
+ ...(options.key !== undefined && { key: options.key }),
+ ...(options.status !== undefined && { status: options.status }),
+ ...(options.upstreamCode !== undefined && { upstreamCode: options.upstreamCode }),
+ };
+
+ if (isValidationError(context)) {
+ return new CloudflareImagesValidationProblem(
+ context,
+ options.detail ?? "Cloudflare Images request validation failed",
+ );
+ }
+
+ if (isRetryableUpstreamError(context)) {
+ return new CloudflareImagesRetryableUpstreamProblem(context);
+ }
+
+ return new CloudflareImagesTerminalUpstreamProblem(context);
+}
+
+function createCloudflareImagesErrorContext(
+ error: unknown,
+ options: {
+ readonly operation: string;
+ readonly key?: string;
+ readonly status?: number;
+ readonly upstreamCode?: string;
+ },
+): CloudflareImagesErrorContext {
+ const record = asRecord(error);
+ const status = firstNumber(options.status, record?.status, record?.statusCode, record?.http_code);
+ const upstreamCode = firstString(options.upstreamCode, record?.code, record?.name);
+
+ return {
+ provider: "cloudflare-images",
+ operation: options.operation,
+ ...(options.key !== undefined && { key: options.key }),
+ ...(status !== undefined && { status }),
+ ...(upstreamCode !== undefined && { upstreamCode }),
+ };
+}
+
+function isValidationError(context: CloudflareImagesErrorContext): boolean {
+ return (
+ context.status === 400 ||
+ context.status === 401 ||
+ context.status === 403 ||
+ context.status === 422 ||
+ context.upstreamCode === "validation-failed" ||
+ context.upstreamCode === "missing-signing-key"
+ );
+}
+
+function isRetryableUpstreamError(context: CloudflareImagesErrorContext): boolean {
+ return (
+ context.status === 408 ||
+ context.status === 425 ||
+ context.status === 429 ||
+ (context.status !== undefined && context.status >= 500) ||
+ context.upstreamCode === "ECONNRESET" ||
+ context.upstreamCode === "ETIMEDOUT" ||
+ context.upstreamCode === "UND_ERR_CONNECT_TIMEOUT"
+ );
+}
+
+function validatePositiveInteger(value: unknown, label: string): void {
+ if (value === undefined) {
+ return;
+ }
+
+ if (
+ typeof value !== "number" ||
+ !Number.isFinite(value) ||
+ !Number.isInteger(value) ||
+ value <= 0
+ ) {
+ throw new CloudflareImagesValidationProblem(
+ {
+ provider: "cloudflare-images",
+ operation: "configuration",
+ upstreamCode: `invalid-${label}`,
+ },
+ `Cloudflare Images configuration '${label}' must be a positive finite integer`,
+ );
+ }
+}
+
+const SENSITIVE_DIAGNOSTIC_KEY =
+ /(authorization|password|secret|token|api[-_]?key|access[-_]?token|signing[-_]?key)/i;
+
+function sanitizeDiagnosticValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map((item) => sanitizeDiagnosticValue(item));
+ }
+
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+
+ if (value instanceof Error) {
+ return {
+ name: value.name,
+ };
+ }
+
+ if (typeof value === "object" && value !== null) {
+ const sanitized: Record = {};
+ for (const [key, nestedValue] of Object.entries(value)) {
+ sanitized[key] = SENSITIVE_DIAGNOSTIC_KEY.test(key)
+ ? "[redacted]"
+ : sanitizeDiagnosticValue(nestedValue);
+ }
+ return sanitized;
+ }
+
+ return value;
+}
+
+function asRecord(value: unknown): Record | undefined {
+ if (typeof value === "object" && value !== null) {
+ return value as Record;
+ }
+
+ return undefined;
+}
+
+function firstNumber(...values: readonly unknown[]): number | undefined {
+ for (const value of values) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+function firstString(...values: readonly unknown[]): string | undefined {
+ for (const value of values) {
+ if (isNonEmptyString(value)) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.length > 0;
+}
diff --git a/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts b/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts
index 5e52d468c..0ed15c1a2 100644
--- a/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts
+++ b/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts
@@ -9,6 +9,10 @@ import type {
UploadIntent,
} from "@croco/storage-core";
import { BaseStorageProvider } from "@croco/storage-core";
+import {
+ createCloudflareImagesResponseProblem,
+ normalizeCloudflareImagesError,
+} from "./CloudflareImagesDiagnosticsProvider";
import type {
CloudflareImageDetails,
CloudflareImagesOptions,
@@ -98,23 +102,37 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
formData.append("file", file);
- const response = await fetch(this.apiBaseUrl, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${this.options.apiToken}`,
+ const response = await this.fetchCloudflare(this.apiBaseUrl, {
+ init: {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${this.options.apiToken}`,
+ },
+ body: formData,
},
- body: formData,
+ key,
+ operation: "put",
});
if (!response.ok) {
const errorText = await response.text();
- this.throwUploadFailed(key, `Cloudflare API error: ${errorText}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "put",
+ key,
+ status: response.status,
+ detail: `Cloudflare Images API error: ${errorText}`,
+ });
}
const result = (await response.json()) as CloudflareUploadResponse;
if (!result.success) {
- this.throwUploadFailed(key, `Cloudflare upload failed: ${result.errors.join(", ")}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "put",
+ key,
+ upstreamCode: "validation-failed",
+ detail: `Cloudflare Images upload failed: ${result.errors.join(", ")}`,
+ });
}
}
@@ -122,14 +140,18 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
this.validateKey(key);
const url = this.buildImageUrl(key, this.options.defaultVariant ?? "public");
- const response = await fetch(url);
+ const response = await this.fetchCloudflare(url, { key, operation: "get" });
if (!response.ok) {
if (response.status === 404) {
this.throwNotFound(key);
}
- this.throwUploadFailed(key, `Failed to fetch image: HTTP ${response.status}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "get",
+ key,
+ status: response.status,
+ });
}
const arrayBuffer = await response.arrayBuffer();
@@ -139,22 +161,36 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
async delete(key: string): Promise {
this.validateKey(key);
- const response = await fetch(`${this.apiBaseUrl}/${key}`, {
- method: "DELETE",
- headers: {
- Authorization: `Bearer ${this.options.apiToken}`,
+ const response = await this.fetchCloudflare(`${this.apiBaseUrl}/${key}`, {
+ init: {
+ method: "DELETE",
+ headers: {
+ Authorization: `Bearer ${this.options.apiToken}`,
+ },
},
+ key,
+ operation: "delete",
});
if (!response.ok) {
const errorText = await response.text();
- this.throwDeleteFailed(key, `Cloudflare delete error: ${errorText}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "delete",
+ key,
+ status: response.status,
+ detail: `Cloudflare Images delete error: ${errorText}`,
+ });
}
const result = await response.json();
if (!result.success) {
- this.throwDeleteFailed(key, `Cloudflare delete failed: ${result.errors.join(", ")}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "delete",
+ key,
+ upstreamCode: "validation-failed",
+ detail: `Cloudflare Images delete failed: ${result.errors.join(", ")}`,
+ });
}
}
@@ -181,10 +217,14 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
): Promise<{ size: number; contentType?: string; lastModified: Date; etag?: string }> {
this.validateKey(key);
- const response = await fetch(`${this.apiBaseUrl}/${key}`, {
- headers: {
- Authorization: `Bearer ${this.options.apiToken}`,
+ const response = await this.fetchCloudflare(`${this.apiBaseUrl}/${key}`, {
+ init: {
+ headers: {
+ Authorization: `Bearer ${this.options.apiToken}`,
+ },
},
+ key,
+ operation: "metadata",
});
if (response.status === 404) {
@@ -193,13 +233,23 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
if (!response.ok) {
const errorText = await response.text();
- this.throwUploadFailed(key, `Cloudflare metadata error: ${errorText}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "metadata",
+ key,
+ status: response.status,
+ detail: `Cloudflare Images metadata error: ${errorText}`,
+ });
}
const result = (await response.json()) as CloudflareImageDetails;
if (!result.success) {
- this.throwUploadFailed(key, `Cloudflare metadata failed: ${result.errors.join(", ")}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "metadata",
+ key,
+ upstreamCode: "validation-failed",
+ detail: `Cloudflare Images metadata failed: ${result.errors.join(", ")}`,
+ });
}
if (!result.result) {
@@ -238,29 +288,43 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
const url = `${this.apiBaseUrl}/direct_upload`;
- const response = await fetch(url, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${this.options.apiToken}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- maxDurationSeconds: ttl,
- metadata: {
- originalKey: key,
+ const response = await this.fetchCloudflare(url, {
+ init: {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${this.options.apiToken}`,
+ "Content-Type": "application/json",
},
- }),
+ body: JSON.stringify({
+ maxDurationSeconds: ttl,
+ metadata: {
+ originalKey: key,
+ },
+ }),
+ },
+ key,
+ operation: "upload-intent",
});
if (!response.ok) {
const errorText = await response.text();
- this.throwUploadFailed(key, `Cloudflare upload intent error: ${errorText}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "upload-intent",
+ key,
+ status: response.status,
+ detail: `Cloudflare Images upload intent error: ${errorText}`,
+ });
}
const result = await response.json();
if (!result.success) {
- this.throwUploadFailed(key, `Cloudflare upload intent failed: ${result.errors.join(", ")}`);
+ throw createCloudflareImagesResponseProblem({
+ operation: "upload-intent",
+ key,
+ upstreamCode: "validation-failed",
+ detail: `Cloudflare Images upload intent failed: ${result.errors.join(", ")}`,
+ });
}
if (!result.result) {
@@ -444,7 +508,12 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
private async getSigningKey(key: string): Promise {
const { signingKey } = this.options;
if (!signingKey) {
- this.throwUploadFailed(key, "Cloudflare signingKey is required for signed URL generation");
+ throw createCloudflareImagesResponseProblem({
+ operation: "signed-url",
+ key,
+ upstreamCode: "missing-signing-key",
+ detail: "Cloudflare signingKey is required for signed URL generation",
+ });
}
const keyData = new TextEncoder().encode(signingKey);
@@ -453,4 +522,22 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima
"sign",
]);
}
+
+ private async fetchCloudflare(
+ input: string,
+ options: {
+ readonly init?: RequestInit;
+ readonly key: string;
+ readonly operation: string;
+ },
+ ): Promise {
+ try {
+ return options.init === undefined ? await fetch(input) : await fetch(input, options.init);
+ } catch (error) {
+ throw normalizeCloudflareImagesError(error, {
+ key: options.key,
+ operation: options.operation,
+ });
+ }
+ }
}
diff --git a/packages/storage-cloudflare/src/tests/CloudflareImagesDiagnosticsProvider.spec.ts b/packages/storage-cloudflare/src/tests/CloudflareImagesDiagnosticsProvider.spec.ts
new file mode 100644
index 000000000..1eb79f6c2
--- /dev/null
+++ b/packages/storage-cloudflare/src/tests/CloudflareImagesDiagnosticsProvider.spec.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+import { CloudflareImagesDiagnosticsProvider } from "../libs/CloudflareImagesDiagnosticsProvider";
+
+const validConfig = {
+ accountHash: "test-account-hash",
+ accountId: "test-account-id",
+ apiToken: "test-api-token",
+ signingKey: "test-signing-key",
+};
+
+describe("CloudflareImagesDiagnosticsProvider", () => {
+ it("reports unhealthy readiness when required configuration is missing", async () => {
+ const provider = new CloudflareImagesDiagnosticsProvider({
+ accountId: "test-account-id",
+ apiToken: "test-api-token",
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "unhealthy",
+ component: "storage-cloudflare",
+ details: {
+ liveCheck: "not_started",
+ problemCode: "storage-cloudflare/missing-config",
+ problemStatus: 500,
+ hasAccountId: true,
+ hasApiToken: true,
+ hasAccountHash: false,
+ },
+ });
+ });
+
+ it("reports healthy readiness without mutating upstream state when live check is not configured", async () => {
+ const provider = new CloudflareImagesDiagnosticsProvider(validConfig);
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ component: "storage-cloudflare",
+ details: {
+ liveCheck: "not_configured",
+ hasAccountId: true,
+ hasApiToken: true,
+ hasAccountHash: true,
+ hasSigningKey: true,
+ metadataSupport: {
+ contentType: "unsupported",
+ customMetadata: "unsupported",
+ },
+ },
+ });
+ });
+
+ it("sanitizes readiness details returned by a live check", async () => {
+ const provider = new CloudflareImagesDiagnosticsProvider(validConfig, {
+ readinessCheck: async () => ({
+ details: {
+ accountId: "test-account-id",
+ apiToken: "must-not-leak",
+ nested: {
+ signingKey: "must-not-leak",
+ },
+ },
+ }),
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ details: {
+ liveCheck: "passed",
+ readiness: {
+ accountId: "test-account-id",
+ apiToken: "[redacted]",
+ nested: {
+ signingKey: "[redacted]",
+ },
+ },
+ },
+ });
+ });
+
+ it("normalizes failed live checks to deterministic provider Problem codes", async () => {
+ const provider = new CloudflareImagesDiagnosticsProvider(validConfig, {
+ readinessCheck: async () => {
+ throw Object.assign(new Error("Cloudflare API unavailable"), { status: 503 });
+ },
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "degraded",
+ component: "storage-cloudflare",
+ details: {
+ liveCheck: "failed",
+ problemCode: "storage-cloudflare/retryable-upstream",
+ problemStatus: 500,
+ },
+ });
+ });
+});
diff --git a/packages/storage-cloudflare/src/tests/CloudflareImagesLiveSmoke.spec.ts b/packages/storage-cloudflare/src/tests/CloudflareImagesLiveSmoke.spec.ts
new file mode 100644
index 000000000..601939cc2
--- /dev/null
+++ b/packages/storage-cloudflare/src/tests/CloudflareImagesLiveSmoke.spec.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { CloudflareImagesDiagnosticsProvider } from "../libs/CloudflareImagesDiagnosticsProvider";
+import type { CloudflareImagesOptions } from "../libs/types";
+
+const CLOUDFLARE_IMAGES_LIVE_ENV = [
+ "CROCO_LIVE_CLOUDFLARE_IMAGES",
+ "CLOUDFLARE_ACCOUNT_ID",
+ "CLOUDFLARE_API_TOKEN",
+ "CLOUDFLARE_ACCOUNT_HASH",
+] as const;
+
+const liveConfig: CloudflareImagesOptions = {
+ accountHash: process.env.CLOUDFLARE_ACCOUNT_HASH ?? "",
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "",
+ apiToken: process.env.CLOUDFLARE_API_TOKEN ?? "",
+};
+
+const missingLiveSmokeEnv = [
+ ...(!isTruthyEnv("CROCO_LIVE_CLOUDFLARE_IMAGES") ? ["CROCO_LIVE_CLOUDFLARE_IMAGES"] : []),
+ ...CLOUDFLARE_IMAGES_LIVE_ENV.filter(
+ (name) => name !== "CROCO_LIVE_CLOUDFLARE_IMAGES" && !process.env[name],
+ ),
+];
+
+describe("Cloudflare Images live smoke", () => {
+ it.skipIf(missingLiveSmokeEnv.length > 0)(
+ "requires CROCO_LIVE_CLOUDFLARE_IMAGES and Cloudflare Images credentials for live readiness smoke",
+ async () => {
+ const provider = new CloudflareImagesDiagnosticsProvider(liveConfig, {
+ readinessCheck: async ({ config, signal }) => {
+ const response = await fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/images/v1?per_page=1`,
+ {
+ headers: {
+ Authorization: `Bearer ${config.apiToken}`,
+ },
+ signal,
+ },
+ );
+
+ if (!response.ok) {
+ throw Object.assign(new Error(`Cloudflare Images readiness HTTP ${response.status}`), {
+ status: response.status,
+ });
+ }
+
+ const payload = (await response.json()) as {
+ readonly result_info?: {
+ readonly count?: number;
+ };
+ readonly success?: boolean;
+ };
+
+ if (payload.success === false) {
+ throw Object.assign(new Error("Cloudflare Images readiness failed"), {
+ code: "validation-failed",
+ });
+ }
+
+ return {
+ details: {
+ accountId: config.accountId,
+ imageCount: payload.result_info?.count ?? 0,
+ },
+ };
+ },
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ component: "storage-cloudflare",
+ details: expect.objectContaining({
+ liveCheck: "passed",
+ }),
+ });
+ },
+ );
+});
+
+function isTruthyEnv(name: string): boolean {
+ const value = process.env[name]?.trim().toLowerCase();
+ return value === "1" || value === "true" || value === "yes";
+}
diff --git a/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts b/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts
index fed1625d7..a4ff297a8 100644
--- a/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts
+++ b/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts
@@ -1,5 +1,5 @@
import { Container } from "@croco/framework-context";
-import { DeleteFailedProblem, FileNotFoundProblem, UploadFailedProblem } from "@croco/storage-core";
+import { FileNotFoundProblem, UploadFailedProblem } from "@croco/storage-core";
import { createStorageProviderConformanceSuite } from "@croco/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CloudflareImagesProvider } from "../libs/CloudflareImagesProvider";
@@ -162,7 +162,7 @@ describe("CloudflareImagesProvider", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
- it("should throw UploadFailedProblem when API returns error", async () => {
+ it("should throw terminal provider Problem when API returns error", async () => {
const mockBuffer = Buffer.from("test-image-data");
const mockResponse = {
ok: false,
@@ -171,10 +171,12 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.put("test.jpg", mockBuffer)).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.put("test.jpg", mockBuffer)).rejects.toMatchObject({
+ code: "storage-cloudflare/terminal-upstream",
+ });
});
- it("should throw UploadFailedProblem when response success is false", async () => {
+ it("should throw validation provider Problem when response success is false", async () => {
const mockBuffer = Buffer.from("test-image-data");
const mockResponse = {
ok: true,
@@ -186,7 +188,9 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.put("test.jpg", mockBuffer)).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.put("test.jpg", mockBuffer)).rejects.toMatchObject({
+ code: "storage-cloudflare/validation-failed",
+ });
});
});
@@ -283,7 +287,7 @@ describe("CloudflareImagesProvider", () => {
);
});
- it("should throw DeleteFailedProblem when delete fails", async () => {
+ it("should throw terminal provider Problem when delete fails", async () => {
const mockResponse = {
ok: false,
text: async () => "Not found",
@@ -291,10 +295,12 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.delete("test-image-id")).rejects.toThrow(DeleteFailedProblem);
+ await expect(provider.delete("test-image-id")).rejects.toMatchObject({
+ code: "storage-cloudflare/terminal-upstream",
+ });
});
- it("should throw DeleteFailedProblem when response success is false", async () => {
+ it("should throw validation provider Problem when response success is false", async () => {
const mockResponse = {
ok: true,
json: async () => ({
@@ -305,7 +311,9 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.delete("test-image-id")).rejects.toThrow(DeleteFailedProblem);
+ await expect(provider.delete("test-image-id")).rejects.toMatchObject({
+ code: "storage-cloudflare/validation-failed",
+ });
});
});
@@ -342,7 +350,9 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.exists("test-image-id")).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.exists("test-image-id")).rejects.toMatchObject({
+ code: "storage-cloudflare/retryable-upstream",
+ });
});
});
@@ -416,7 +426,7 @@ describe("CloudflareImagesProvider", () => {
expect(url).toContain("cdn.example.com");
});
- it("should throw UploadFailedProblem when signingKey is missing", async () => {
+ it("should throw validation provider Problem when signingKey is missing", async () => {
const providerWithoutSigningKey = new CloudflareImagesProvider({
accountId: "test-account-id",
apiToken: "test-api-token",
@@ -426,7 +436,9 @@ describe("CloudflareImagesProvider", () => {
await expect(
providerWithoutSigningKey.getSignedUrl("test-image-id", { expiresIn: 3600 }),
- ).rejects.toThrow(UploadFailedProblem);
+ ).rejects.toMatchObject({
+ code: "storage-cloudflare/validation-failed",
+ });
});
});
@@ -469,7 +481,7 @@ describe("CloudflareImagesProvider", () => {
await expect(provider.getMetadata("non-existent-id")).rejects.toThrow(FileNotFoundProblem);
});
- it("should throw UploadFailedProblem when API returns error", async () => {
+ it("should throw terminal provider Problem when API returns error", async () => {
const mockResponse = {
ok: false,
text: async () => "Unauthorized",
@@ -477,7 +489,9 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.getMetadata("test-image-id")).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.getMetadata("test-image-id")).rejects.toMatchObject({
+ code: "storage-cloudflare/terminal-upstream",
+ });
});
it("should handle missing size field", async () => {
@@ -730,7 +744,7 @@ describe("CloudflareImagesProvider", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
- it("should throw UploadFailedProblem when API returns error", async () => {
+ it("should throw terminal provider Problem when API returns error", async () => {
const mockResponse = {
ok: false,
text: async () => "Unauthorized",
@@ -738,10 +752,12 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.getUploadIntent("new-image.jpg")).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.getUploadIntent("new-image.jpg")).rejects.toMatchObject({
+ code: "storage-cloudflare/terminal-upstream",
+ });
});
- it("should throw UploadFailedProblem when response success is false", async () => {
+ it("should throw validation provider Problem when response success is false", async () => {
const mockResponse = {
ok: true,
json: async () => ({
@@ -752,7 +768,9 @@ describe("CloudflareImagesProvider", () => {
mockFetch.mockResolvedValueOnce(mockResponse);
- await expect(provider.getUploadIntent("new-image.jpg")).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.getUploadIntent("new-image.jpg")).rejects.toMatchObject({
+ code: "storage-cloudflare/validation-failed",
+ });
});
it("should use custom domain for publicUrl when configured", async () => {
diff --git a/packages/storage-cloudflare/tsconfig.json b/packages/storage-cloudflare/tsconfig.json
index a39dbd865..d785efdeb 100644
--- a/packages/storage-cloudflare/tsconfig.json
+++ b/packages/storage-cloudflare/tsconfig.json
@@ -1,5 +1,11 @@
{
"exclude": ["node_modules"],
"extends": "../../tsconfig/tsconfig.node.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@croco/testing": ["../testing/src/libs/provider-conformance.ts"]
+ }
+ },
"include": ["src/**/*.ts"]
}
diff --git a/packages/storage-cloudflare/vitest.config.ts b/packages/storage-cloudflare/vitest.config.ts
index 50fa2037c..54a326520 100644
--- a/packages/storage-cloudflare/vitest.config.ts
+++ b/packages/storage-cloudflare/vitest.config.ts
@@ -7,8 +7,12 @@ const currentDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
+ "@croco/diagnostics-core": resolve(currentDir, "../diagnostics-core/src/index.ts"),
+ "@croco/framework-context": resolve(currentDir, "../framework-context/src/index.ts"),
"@croco/problems-core": resolve(currentDir, "../problems-core/src/index.ts"),
"@croco/storage-core": resolve(currentDir, "../storage-core/src/index.ts"),
+ "@croco/telemetry-api": resolve(currentDir, "../telemetry-api/src/index.ts"),
+ "@croco/testing": resolve(currentDir, "../testing/src/libs/provider-conformance.ts"),
},
},
test: {
diff --git a/packages/storage-cloudinary/README.md b/packages/storage-cloudinary/README.md
index 24eac5879..4f523b390 100644
--- a/packages/storage-cloudinary/README.md
+++ b/packages/storage-cloudinary/README.md
@@ -46,16 +46,61 @@ const transformed = provider.getTransformUrl("uploads/hero.jpg", {
## API 레퍼런스
-| API | 설명 |
-| ---------------------------- | --------------------------------------------------------------------- |
-| `CloudinaryProvider` | 업로드, 다운로드, 삭제, 메타데이터 조회와 변환 URL 생성을 담당합니다. |
-| `CLOUDINARY_CONFIG` | DI 등록용 토큰입니다. |
-| `CloudinaryConfig` | 제공자 설정 타입입니다. |
-| `CloudinaryUploadOptions` | 업로드 확장 옵션 타입입니다. |
-| `CloudinaryTransformOptions` | 변환 파라미터 타입입니다. |
+| API | 설명 |
+| ----------------------------------- | ----------------------------------------------------------------------------- |
+| `CloudinaryProvider` | 업로드, 다운로드, 삭제, 메타데이터 조회와 변환 URL 생성을 담당합니다. |
+| `CloudinaryDiagnosticsProvider` | 안전한 설정 상태와 선택적 readiness check 결과를 `HealthStatus`로 노출합니다. |
+| `CLOUDINARY_CONFIG` | DI 등록용 토큰입니다. |
+| `validateCloudinaryConfig()` | 필수 설정과 양수 정수 옵션을 검증합니다. |
+| `normalizeCloudinaryStorageError()` | Cloudinary/fetch 실패를 provider 전용 `Problem`으로 정규화합니다. |
+| `CloudinaryConfig` | 제공자 설정 타입입니다. |
+| `CloudinaryUploadOptions` | 업로드 확장 옵션 타입입니다. |
+| `CloudinaryTransformOptions` | 변환 파라미터 타입입니다. |
+
+## 진단과 readiness
+
+```typescript
+import { CloudinaryDiagnosticsProvider } from "@croco/storage-cloudinary";
+
+const diagnostics = new CloudinaryDiagnosticsProvider({
+ cloudName: process.env.CLOUDINARY_CLOUD_NAME,
+ apiKey: process.env.CLOUDINARY_API_KEY,
+ apiSecret: process.env.CLOUDINARY_API_SECRET,
+});
+
+const health = await diagnostics.getHealth();
+```
+
+- 필수 설정이 빠지면 `unhealthy` 상태와 `storage-cloudinary/missing-config` 코드가 반환됩니다.
+- `readinessCheck`를 넘기지 않으면 외부 API를 호출하지 않고 설정 존재 여부만 `healthy`로 보고합니다.
+- `readinessCheck`가 실패하면 `degraded` 상태와 정규화된 provider Problem 코드가 반환됩니다.
+- 진단 detail은 토큰, secret, authorization 값을 redaction 처리합니다.
+
+## 실패 코드
+
+| 코드 | 의미 |
+| --------------------------------------- | ------------------------------------------------------ |
+| `storage-cloudinary/missing-config` | `cloudName`, `apiKey`, `apiSecret` 중 하나가 없음 |
+| `storage-cloudinary/validation-failed` | 400, 401, 403, 422 응답 또는 잘못된 설정/입력 실패 |
+| `storage-cloudinary/retryable-upstream` | 408, 425, 429, 5xx 또는 네트워크 계열 재시도 가능 실패 |
+| `storage-cloudinary/terminal-upstream` | 재시도 대상으로 분류되지 않는 Cloudinary/fetch 실패 |
+
+## 선택적 live smoke
+
+기본 테스트는 실제 Cloudinary 자격 증명을 요구하지 않습니다. 실제 backend readiness를 확인하려면 아래 환경 변수를 모두 설정한 뒤 live smoke를 opt-in 합니다.
+
+```bash
+CROCO_LIVE_CLOUDINARY=1 \
+CLOUDINARY_CLOUD_NAME=... \
+CLOUDINARY_API_KEY=... \
+CLOUDINARY_API_SECRET=... \
+pnpm --filter @croco/storage-cloudinary test -- CloudinaryLiveSmoke
+```
## 동작 메모
- `cover`, `contain`, `fill`, `inside`, `outside`를 Cloudinary crop 값으로 변환합니다.
- 일시적 네트워크 오류와 5xx 응답은 최대 3회 재시도합니다.
- 업로드 인텐트는 직접 업로드 엔드포인트 URL과 공개 URL을 함께 반환합니다.
+- `StorageProvider`의 `list()` 계약은 아직 존재하지 않으므로 provider도 목록 조회를 제공하지 않습니다.
+- custom metadata는 Cloudinary context로 보존됩니다. `getMetadata().contentType`은 원래 MIME 전체가 아니라 Cloudinary resource `format` 값입니다.
diff --git a/packages/storage-cloudinary/package.json b/packages/storage-cloudinary/package.json
index 299ec00cd..4701278be 100644
--- a/packages/storage-cloudinary/package.json
+++ b/packages/storage-cloudinary/package.json
@@ -40,11 +40,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@croco/diagnostics-core": "workspace:*",
"@croco/framework-context": "workspace:*",
"@croco/problems-core": "workspace:*",
"@croco/retry-core": "workspace:*",
"@croco/storage-core": "workspace:*",
"cloudinary": "^2.10.0"
},
- "devDependencies": {}
+ "devDependencies": {
+ "@croco/testing": "workspace:*"
+ }
}
diff --git a/packages/storage-cloudinary/src/index.ts b/packages/storage-cloudinary/src/index.ts
index d23dbeb23..65deed056 100644
--- a/packages/storage-cloudinary/src/index.ts
+++ b/packages/storage-cloudinary/src/index.ts
@@ -2,6 +2,17 @@
* Cloudinary 기반 스토리지 및 이미지 변환 제공자 구현체입니다.
*/
export { CloudinaryProvider } from "./libs/CloudinaryProvider";
+export {
+ CloudinaryDiagnosticsProvider,
+ CloudinaryMissingConfigProblem,
+ CloudinaryRetryableUpstreamProblem,
+ CloudinaryTerminalUpstreamProblem,
+ CloudinaryValidationProblem,
+ getCloudinaryErrorMessage,
+ isRetryableCloudinaryStorageError,
+ normalizeCloudinaryStorageError,
+ validateCloudinaryConfig,
+} from "./libs/CloudinaryDiagnosticsProvider";
/**
* Cloudinary 설정을 DI 컨테이너에 등록할 때 사용하는 토큰입니다.
@@ -11,6 +22,13 @@ export { CLOUDINARY_CONFIG } from "./libs/tokens";
/**
* Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다.
*/
+export type {
+ CloudinaryConfigKey,
+ CloudinaryDiagnosticsOptions,
+ CloudinaryReadinessCheckContext,
+ CloudinaryReadinessCheckResult,
+ CloudinaryStorageErrorContext,
+} from "./libs/CloudinaryDiagnosticsProvider";
export type {
CloudinaryConfig,
CloudinaryTransformOptions,
diff --git a/packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts b/packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts
new file mode 100644
index 000000000..54acfd6ae
--- /dev/null
+++ b/packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts
@@ -0,0 +1,457 @@
+import type { DiagnosticsProvider, HealthStatus } from "@croco/diagnostics-core";
+import { Problem, ProblemCategory } from "@croco/problems-core";
+import type { CloudinaryConfig } from "./types";
+
+const TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
+const TRANSIENT_ERROR_CODES = new Set([
+ "ECONNABORTED",
+ "ECONNREFUSED",
+ "ECONNRESET",
+ "EAI_AGAIN",
+ "ENETDOWN",
+ "ENETRESET",
+ "ENETUNREACH",
+ "ENOTFOUND",
+ "ETIMEDOUT",
+ "UND_ERR_BODY_TIMEOUT",
+ "UND_ERR_CONNECT_TIMEOUT",
+ "UND_ERR_HEADERS_TIMEOUT",
+ "UND_ERR_SOCKET",
+]);
+
+export type CloudinaryStorageErrorContext = {
+ readonly provider: "cloudinary";
+ readonly operation: string;
+ readonly key?: string;
+ readonly status?: number;
+ readonly upstreamCode?: string;
+ readonly retryable?: boolean;
+};
+
+export type CloudinaryReadinessCheckContext = {
+ readonly config: CloudinaryConfig;
+ readonly signal?: AbortSignal;
+};
+
+export type CloudinaryReadinessCheckResult = {
+ readonly message?: string;
+ readonly details?: Record;
+};
+
+export type CloudinaryDiagnosticsOptions = {
+ readonly readinessCheck?: (
+ context: CloudinaryReadinessCheckContext,
+ ) => Promise;
+};
+
+export type CloudinaryConfigKey = "apiKey" | "apiSecret" | "cloudName";
+
+export class CloudinaryMissingConfigProblem extends Problem {
+ constructor(configKey: CloudinaryConfigKey, operation = "configuration") {
+ super(
+ "storage-cloudinary/missing-config",
+ ProblemCategory.InternalServerError,
+ `Cloudinary configuration is missing required value '${configKey}'`,
+ {
+ extensions: {
+ provider: "cloudinary",
+ operation,
+ configKey,
+ },
+ },
+ );
+ }
+}
+
+export class CloudinaryValidationProblem extends Problem {
+ constructor(
+ context: CloudinaryStorageErrorContext,
+ detail = "Cloudinary storage request validation failed",
+ ) {
+ super(
+ "storage-cloudinary/validation-failed",
+ ProblemCategory.ValidationError,
+ `${detail} during ${context.operation}`,
+ {
+ extensions: context,
+ },
+ );
+ }
+}
+
+export class CloudinaryRetryableUpstreamProblem extends Problem {
+ constructor(context: CloudinaryStorageErrorContext) {
+ super(
+ "storage-cloudinary/retryable-upstream",
+ ProblemCategory.InternalServerError,
+ `Cloudinary upstream request failed retryably during ${context.operation}`,
+ {
+ extensions: {
+ ...context,
+ retryable: true,
+ },
+ },
+ );
+ }
+}
+
+export class CloudinaryTerminalUpstreamProblem extends Problem {
+ constructor(context: CloudinaryStorageErrorContext) {
+ super(
+ "storage-cloudinary/terminal-upstream",
+ ProblemCategory.InternalServerError,
+ `Cloudinary upstream request failed terminally during ${context.operation}`,
+ {
+ extensions: {
+ ...context,
+ retryable: false,
+ },
+ },
+ );
+ }
+}
+
+export class CloudinaryDiagnosticsProvider implements DiagnosticsProvider {
+ readonly name = "storage-cloudinary";
+
+ constructor(
+ private readonly config: Partial,
+ private readonly options: CloudinaryDiagnosticsOptions = {},
+ ) {}
+
+ async getHealth(signal?: AbortSignal): Promise {
+ const baseDetails = this.createSafeConfigDetails();
+ let validConfig: CloudinaryConfig;
+
+ try {
+ validConfig = validateCloudinaryConfig(this.config);
+ } catch (error) {
+ const problem =
+ error instanceof Problem
+ ? error
+ : normalizeCloudinaryStorageError(error, { operation: "configuration" });
+
+ return {
+ status: "unhealthy",
+ component: this.name,
+ message: problem.detail,
+ details: {
+ ...baseDetails,
+ liveCheck: "not_started",
+ problemCode: problem.code,
+ problemStatus: problem.status,
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ }
+
+ if (!this.options.readinessCheck) {
+ return {
+ status: "healthy",
+ component: this.name,
+ message:
+ "Cloudinary configuration is present; live upstream readiness check is not configured",
+ details: {
+ ...baseDetails,
+ liveCheck: "not_configured",
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ }
+
+ try {
+ const result = await this.options.readinessCheck({ config: validConfig, signal });
+
+ return {
+ status: "healthy",
+ component: this.name,
+ message: result?.message ?? "Cloudinary readiness check passed",
+ details: {
+ ...baseDetails,
+ liveCheck: "passed",
+ ...(result?.details && { readiness: sanitizeDiagnosticValue(result.details) }),
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ } catch (error) {
+ const problem = normalizeCloudinaryStorageError(error, { operation: "readiness" });
+
+ return {
+ status: "degraded",
+ component: this.name,
+ message: problem.detail,
+ details: {
+ ...baseDetails,
+ liveCheck: "failed",
+ problemCode: problem.code,
+ problemStatus: problem.status,
+ },
+ lastChecked: new Date().toISOString(),
+ };
+ }
+ }
+
+ private createSafeConfigDetails(): Record {
+ return {
+ provider: "cloudinary",
+ hasCloudName: isNonEmptyString(this.config.cloudName),
+ hasApiKey: isNonEmptyString(this.config.apiKey),
+ hasApiSecret: isNonEmptyString(this.config.apiSecret),
+ secure: this.config.secure ?? true,
+ hasUploadBaseUrl: isNonEmptyString(this.config.uploadBaseUrl),
+ metadataSupport: {
+ contentType: "format-only",
+ customMetadata: "required",
+ },
+ };
+ }
+}
+
+export function validateCloudinaryConfig(config: Partial): CloudinaryConfig {
+ if (!isNonEmptyString(config.cloudName)) {
+ throw new CloudinaryMissingConfigProblem("cloudName");
+ }
+
+ if (!isNonEmptyString(config.apiKey)) {
+ throw new CloudinaryMissingConfigProblem("apiKey");
+ }
+
+ if (!isNonEmptyString(config.apiSecret)) {
+ throw new CloudinaryMissingConfigProblem("apiSecret");
+ }
+
+ validatePositiveInteger(config.ttl, "ttl");
+
+ return config as CloudinaryConfig;
+}
+
+export function normalizeCloudinaryStorageError(
+ error: unknown,
+ options: {
+ readonly operation: string;
+ readonly key?: string;
+ readonly status?: number;
+ readonly upstreamCode?: string;
+ },
+): Problem {
+ if (error instanceof Problem) {
+ return error;
+ }
+
+ const context = createCloudinaryStorageErrorContext(error, options);
+
+ if (isNotFoundError(context, error)) {
+ return new CloudinaryTerminalUpstreamProblem(context);
+ }
+
+ if (isValidationError(context)) {
+ return new CloudinaryValidationProblem(context);
+ }
+
+ if (isRetryableCloudinaryStorageError(error, context)) {
+ return new CloudinaryRetryableUpstreamProblem(context);
+ }
+
+ return new CloudinaryTerminalUpstreamProblem(context);
+}
+
+export function isRetryableCloudinaryStorageError(
+ error: unknown,
+ knownContext?: CloudinaryStorageErrorContext,
+): boolean {
+ const context =
+ knownContext ?? createCloudinaryStorageErrorContext(error, { operation: "unknown" });
+
+ if (context.status !== undefined) {
+ if (context.status === 404) {
+ return false;
+ }
+
+ if (TRANSIENT_HTTP_STATUSES.has(context.status)) {
+ return true;
+ }
+ }
+
+ if (context.upstreamCode && TRANSIENT_ERROR_CODES.has(context.upstreamCode)) {
+ return true;
+ }
+
+ const message = getCloudinaryErrorMessage(error, "").toLowerCase();
+
+ return [
+ "connection reset",
+ "connect timeout",
+ "econnreset",
+ "fetch failed",
+ "network error",
+ "rate limit",
+ "socket hang up",
+ "temporarily unavailable",
+ "timed out",
+ "timeout",
+ "too many requests",
+ "try again",
+ ].some((pattern) => message.includes(pattern));
+}
+
+export function getCloudinaryErrorMessage(error: unknown, fallback: string): string {
+ if (error instanceof Error && error.message.length > 0) {
+ return error.message;
+ }
+
+ const record = asRecord(error);
+ const message = record?.message;
+ if (typeof message === "string" && message.length > 0) {
+ return message;
+ }
+
+ const nestedMessage = asRecord(record?.error)?.message;
+ if (typeof nestedMessage === "string" && nestedMessage.length > 0) {
+ return nestedMessage;
+ }
+
+ return fallback;
+}
+
+function createCloudinaryStorageErrorContext(
+ error: unknown,
+ options: {
+ readonly operation: string;
+ readonly key?: string;
+ readonly status?: number;
+ readonly upstreamCode?: string;
+ },
+): CloudinaryStorageErrorContext {
+ const record = asRecord(error);
+ const extensions = asRecord(record?.extensions);
+ const nestedError = asRecord(record?.error);
+ const status = firstNumber(
+ options.status,
+ record?.status,
+ record?.http_code,
+ record?.statusCode,
+ nestedError?.status,
+ nestedError?.http_code,
+ nestedError?.statusCode,
+ extensions?.status,
+ );
+ const upstreamCode = firstString(
+ options.upstreamCode,
+ extensions?.upstreamCode,
+ record?.code,
+ record?.name,
+ nestedError?.code,
+ nestedError?.name,
+ typeof record?.error === "string" ? record.error : undefined,
+ nestedError?.message,
+ );
+
+ return {
+ provider: "cloudinary",
+ operation: options.operation,
+ ...(options.key !== undefined && { key: options.key }),
+ ...(status !== undefined && { status }),
+ ...(upstreamCode !== undefined && { upstreamCode }),
+ };
+}
+
+function isNotFoundError(context: CloudinaryStorageErrorContext, error: unknown): boolean {
+ return (
+ context.status === 404 ||
+ getCloudinaryErrorMessage(error, "").toLowerCase().includes("not found")
+ );
+}
+
+function isValidationError(context: CloudinaryStorageErrorContext): boolean {
+ return (
+ context.status === 400 ||
+ context.status === 401 ||
+ context.status === 403 ||
+ context.status === 422
+ );
+}
+
+function validatePositiveInteger(value: unknown, label: string): void {
+ if (value === undefined) {
+ return;
+ }
+
+ if (
+ typeof value !== "number" ||
+ !Number.isFinite(value) ||
+ !Number.isInteger(value) ||
+ value <= 0
+ ) {
+ throw new CloudinaryValidationProblem(
+ {
+ provider: "cloudinary",
+ operation: "configuration",
+ upstreamCode: `invalid-${label}`,
+ },
+ `Cloudinary configuration '${label}' must be a positive finite integer`,
+ );
+ }
+}
+
+const SENSITIVE_DIAGNOSTIC_KEY =
+ /(authorization|password|secret|token|api[-_]?key|access[-_]?token)/i;
+
+function sanitizeDiagnosticValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map((item) => sanitizeDiagnosticValue(item));
+ }
+
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+
+ if (value instanceof Error) {
+ return {
+ name: value.name,
+ };
+ }
+
+ if (typeof value === "object" && value !== null) {
+ const sanitized: Record = {};
+ for (const [key, nestedValue] of Object.entries(value)) {
+ sanitized[key] = SENSITIVE_DIAGNOSTIC_KEY.test(key)
+ ? "[redacted]"
+ : sanitizeDiagnosticValue(nestedValue);
+ }
+ return sanitized;
+ }
+
+ return value;
+}
+
+function asRecord(value: unknown): Record | undefined {
+ if (typeof value === "object" && value !== null) {
+ return value as Record;
+ }
+
+ return undefined;
+}
+
+function firstNumber(...values: readonly unknown[]): number | undefined {
+ for (const value of values) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+function firstString(...values: readonly unknown[]): string | undefined {
+ for (const value of values) {
+ if (isNonEmptyString(value)) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.length > 0;
+}
diff --git a/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts b/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts
index bfb51be07..92de73998 100644
--- a/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts
+++ b/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts
@@ -13,143 +13,92 @@ import type {
} from "@croco/storage-core";
import { BaseStorageProvider } from "@croco/storage-core";
import { v2 as cloudinary } from "cloudinary";
+import {
+ CloudinaryTerminalUpstreamProblem,
+ getCloudinaryErrorMessage,
+ isRetryableCloudinaryStorageError,
+ normalizeCloudinaryStorageError,
+} from "./CloudinaryDiagnosticsProvider";
import type { CloudinaryConfig, CloudinaryTransformOptions } from "./types";
-const TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
-const TRANSIENT_ERROR_CODES = new Set([
- "ECONNABORTED",
- "ECONNREFUSED",
- "ECONNRESET",
- "EAI_AGAIN",
- "ENETDOWN",
- "ENETRESET",
- "ENETUNREACH",
- "ENOTFOUND",
- "ETIMEDOUT",
- "UND_ERR_BODY_TIMEOUT",
- "UND_ERR_CONNECT_TIMEOUT",
- "UND_ERR_HEADERS_TIMEOUT",
- "UND_ERR_SOCKET",
-]);
const CLOUDINARY_RETRY_POLICY: RetryPolicy = {
shouldRetry(error: unknown, attempt: number, maxAttempts: number) {
if (attempt >= maxAttempts) {
return false;
}
- return isRetryableCloudinaryError(error);
+ return isRetryableCloudinaryStorageError(error);
},
};
-function getErrorStatus(error: unknown): number | undefined {
- if (typeof error !== "object" || error === null) {
- return undefined;
- }
-
- const status = Reflect.get(error, "status");
- if (typeof status === "number") {
- return status;
- }
-
- const httpCode = Reflect.get(error, "http_code");
- if (typeof httpCode === "number") {
- return httpCode;
- }
-
- const statusCode = Reflect.get(error, "statusCode");
- if (typeof statusCode === "number") {
- return statusCode;
- }
-
- return undefined;
-}
-
-function getErrorCode(error: unknown): string | undefined {
- if (typeof error !== "object" || error === null || !("code" in error)) {
- return undefined;
- }
-
- const code = Reflect.get(error, "code");
- return typeof code === "string" ? code : undefined;
-}
-
-function getErrorMessage(error: unknown): string {
- if (error instanceof Error) {
- return error.message;
- }
-
- if (typeof error === "object" && error !== null && "message" in error) {
- const message = Reflect.get(error, "message");
- if (typeof message === "string") {
- return message;
- }
- }
-
- return "";
-}
-
-function isRetryableCloudinaryError(error: unknown): boolean {
- const status = getErrorStatus(error);
- if (status !== undefined) {
- if (status === 404) {
- return false;
- }
-
- if (TRANSIENT_HTTP_STATUSES.has(status)) {
- return true;
- }
- }
-
- const code = getErrorCode(error);
- if (code && TRANSIENT_ERROR_CODES.has(code)) {
- return true;
- }
-
- const message = getErrorMessage(error).toLowerCase();
-
- return [
- "connection reset",
- "connect timeout",
- "econnreset",
- "fetch failed",
- "network error",
- "rate limit",
- "socket hang up",
- "temporarily unavailable",
- "timed out",
- "timeout",
- "too many requests",
- "try again",
- ].some((pattern) => message.includes(pattern));
-}
-
-type CloudinaryError = Error & {
+type CloudinarySdkError = Error & {
code?: string;
http_code?: number;
status?: number;
statusCode?: number;
};
-function normalizeCloudinaryError(error: unknown, fallbackMessage: string): CloudinaryError {
+function toCloudinarySdkError(error: unknown, fallbackMessage: string): CloudinarySdkError {
if (error instanceof Error) {
- return error as CloudinaryError;
+ return error as CloudinarySdkError;
+ }
+
+ const sdkError = new Error(
+ getCloudinaryErrorMessage(error, fallbackMessage),
+ ) as CloudinarySdkError;
+ const record =
+ typeof error === "object" && error !== null ? (error as Record) : undefined;
+ const nestedError =
+ typeof record?.error === "object" && record.error !== null
+ ? (record.error as Record)
+ : undefined;
+
+ const code = firstString(
+ record?.code,
+ record?.name,
+ nestedError?.code,
+ nestedError?.name,
+ typeof record?.error === "string" ? record.error : undefined,
+ );
+ if (code !== undefined) {
+ sdkError.code = code;
+ }
+
+ const status = firstNumber(
+ record?.http_code,
+ record?.status,
+ record?.statusCode,
+ nestedError?.http_code,
+ nestedError?.status,
+ nestedError?.statusCode,
+ );
+ if (status !== undefined) {
+ sdkError.http_code = status;
+ sdkError.status = status;
+ sdkError.statusCode = status;
}
- const normalizedError = new Error(getErrorMessage(error) || fallbackMessage) as CloudinaryError;
- const code = getErrorCode(error);
- const status = getErrorStatus(error);
+ return sdkError;
+}
- if (code) {
- normalizedError.code = code;
+function firstNumber(...values: readonly unknown[]): number | undefined {
+ for (const value of values) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
}
- if (status !== undefined) {
- normalizedError.status = status;
- normalizedError.statusCode = status;
- normalizedError.http_code = status;
+ return undefined;
+}
+
+function firstString(...values: readonly unknown[]): string | undefined {
+ for (const value of values) {
+ if (typeof value === "string" && value.length > 0) {
+ return value;
+ }
}
- return normalizedError;
+ return undefined;
}
/**
@@ -208,9 +157,9 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
try {
uploadStream = cloudinary.uploader.upload_stream(
uploadOptions,
- (error: Error | undefined, _result: unknown) => {
+ (error: unknown, _result: unknown) => {
if (error) {
- reject(normalizeCloudinaryError(error, "Unknown upload error"));
+ reject(toCloudinarySdkError(error, "Unknown Cloudinary upload error"));
return;
}
@@ -218,7 +167,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
},
);
} catch (error) {
- reject(normalizeCloudinaryError(error, "Unknown upload error"));
+ reject(toCloudinarySdkError(error, "Unknown Cloudinary upload error"));
return;
}
@@ -239,7 +188,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
const uploadPromise = Buffer.isBuffer(data) ? this.executeWithRetry(upload) : upload();
return uploadPromise.catch((error) => {
- this.throwUploadFailed(key, this.getErrorMessage(error, "Unknown upload error"));
+ throw normalizeCloudinaryStorageError(error, { key, operation: "put" });
});
}
@@ -257,14 +206,13 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
this.throwNotFound(key);
}
- if (TRANSIENT_HTTP_STATUSES.has(fetchedResponse.status)) {
- throw this.createHttpError(
- fetchedResponse.status,
- `Failed to fetch file: HTTP ${fetchedResponse.status}`,
- );
- }
-
- this.throwUploadFailed(key, `Failed to fetch file: HTTP ${fetchedResponse.status}`);
+ throw normalizeCloudinaryStorageError(
+ {
+ message: `Failed to fetch file: HTTP ${fetchedResponse.status}`,
+ status: fetchedResponse.status,
+ },
+ { key, operation: "get", status: fetchedResponse.status },
+ );
}
return fetchedResponse;
@@ -273,15 +221,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
- if (
- error &&
- typeof error === "object" &&
- "code" in error &&
- error.code === "STORAGE_FILE_NOT_FOUND"
- ) {
- throw error;
- }
- this.throwUploadFailed(key, this.getErrorMessage(error, "Unknown error"));
+ throw normalizeCloudinaryStorageError(error, { key, operation: "get" });
}
}
@@ -296,23 +236,19 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
await cloudinary.uploader.destroy(key, {
resource_type: "image",
}),
- "Unknown delete error",
),
);
if (result.result !== "ok" && result.result !== "not found") {
- this.throwDeleteFailed(key, `Delete failed: ${result.result}`);
+ throw new CloudinaryTerminalUpstreamProblem({
+ provider: "cloudinary",
+ operation: "delete",
+ key,
+ upstreamCode: result.result,
+ });
}
} catch (error) {
- if (
- error &&
- typeof error === "object" &&
- "code" in error &&
- error.code === "STORAGE_DELETE_FAILED"
- ) {
- throw error;
- }
- this.throwDeleteFailed(key, error instanceof Error ? error.message : "Unknown error");
+ throw normalizeCloudinaryStorageError(error, { key, operation: "delete" });
}
}
@@ -349,7 +285,6 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
await cloudinary.api.resource(key, {
resource_type: "image",
}),
- "Unknown metadata error",
),
)) as {
bytes?: number;
@@ -371,7 +306,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
this.throwNotFound(key);
}
- this.throwUploadFailed(key, this.getErrorMessage(error, "Unknown metadata error"));
+ throw normalizeCloudinaryStorageError(error, { key, operation: "metadata" });
}
}
@@ -422,20 +357,11 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
return await this.retryTemplate.execute(async () => await operation());
}
- private createHttpError(status: number, message: string): Error & { status: number } {
- const error = new Error(message) as Error & { status: number };
- error.status = status;
- return error;
- }
-
- private async executeCloudinaryOperation(
- operation: () => Promise,
- fallbackMessage: string,
- ): Promise {
+ private async executeCloudinaryOperation(operation: () => Promise): Promise {
try {
return await this.withConfiguredCloudinary(operation);
} catch (error) {
- throw normalizeCloudinaryError(error, fallbackMessage);
+ throw toCloudinarySdkError(error, "Unknown Cloudinary error");
}
}
@@ -605,27 +531,19 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv
}
private isNotFoundError(error: unknown): boolean {
- const status = getErrorStatus(error);
- if (status === 404) {
+ if (typeof error === "object" && error !== null && Reflect.get(error, "http_code") === 404) {
return true;
}
- const message = this.getErrorMessage(error, "").toLowerCase();
- return message.includes("not found");
- }
-
- private getErrorMessage(error: unknown, fallback: string): string {
- if (error instanceof Error) {
- return error.message;
+ if (typeof error === "object" && error !== null && Reflect.get(error, "status") === 404) {
+ return true;
}
- if (typeof error === "object" && error !== null && "message" in error) {
- const message = Reflect.get(error, "message");
- if (typeof message === "string" && message.length > 0) {
- return message;
- }
+ if (typeof error === "object" && error !== null && Reflect.get(error, "statusCode") === 404) {
+ return true;
}
- return fallback;
+ const message = getCloudinaryErrorMessage(error, "").toLowerCase();
+ return message.includes("not found");
}
}
diff --git a/packages/storage-cloudinary/src/tests/CloudinaryDiagnosticsProvider.spec.ts b/packages/storage-cloudinary/src/tests/CloudinaryDiagnosticsProvider.spec.ts
new file mode 100644
index 000000000..cb0348503
--- /dev/null
+++ b/packages/storage-cloudinary/src/tests/CloudinaryDiagnosticsProvider.spec.ts
@@ -0,0 +1,142 @@
+import { v2 as cloudinary } from "cloudinary";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ CloudinaryDiagnosticsProvider,
+ getCloudinaryErrorMessage,
+ normalizeCloudinaryStorageError,
+} from "../libs/CloudinaryDiagnosticsProvider";
+
+const validConfig = {
+ apiKey: "test-api-key",
+ apiSecret: "test-api-secret",
+ cloudName: "test-cloud",
+ secure: true,
+};
+
+describe("CloudinaryDiagnosticsProvider", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("reports unhealthy readiness when required configuration is missing", async () => {
+ const provider = new CloudinaryDiagnosticsProvider({
+ apiKey: "test-api-key",
+ apiSecret: "test-api-secret",
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "unhealthy",
+ component: "storage-cloudinary",
+ details: {
+ liveCheck: "not_started",
+ problemCode: "storage-cloudinary/missing-config",
+ problemStatus: 500,
+ hasCloudName: false,
+ hasApiKey: true,
+ hasApiSecret: true,
+ },
+ });
+ });
+
+ it("reports healthy readiness without mutating global Cloudinary config when live check is not configured", async () => {
+ const configSpy = vi.spyOn(cloudinary, "config");
+ const provider = new CloudinaryDiagnosticsProvider(validConfig);
+
+ const health = await provider.getHealth();
+
+ expect(configSpy).not.toHaveBeenCalled();
+ expect(health).toMatchObject({
+ status: "healthy",
+ component: "storage-cloudinary",
+ details: {
+ liveCheck: "not_configured",
+ hasCloudName: true,
+ hasApiKey: true,
+ hasApiSecret: true,
+ metadataSupport: {
+ contentType: "format-only",
+ customMetadata: "required",
+ },
+ },
+ });
+ });
+
+ it("sanitizes readiness details returned by a live check", async () => {
+ const provider = new CloudinaryDiagnosticsProvider(validConfig, {
+ readinessCheck: async () => ({
+ details: {
+ cloudName: "test-cloud",
+ apiKey: "must-not-leak",
+ nested: {
+ apiSecret: "must-not-leak",
+ },
+ },
+ }),
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ details: {
+ liveCheck: "passed",
+ readiness: {
+ cloudName: "test-cloud",
+ apiKey: "[redacted]",
+ nested: {
+ apiSecret: "[redacted]",
+ },
+ },
+ },
+ });
+ });
+
+ it("normalizes failed live checks to deterministic provider Problem codes", async () => {
+ const provider = new CloudinaryDiagnosticsProvider(validConfig, {
+ readinessCheck: async () => {
+ throw { http_code: 403, message: "Forbidden" };
+ },
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "degraded",
+ component: "storage-cloudinary",
+ details: {
+ liveCheck: "failed",
+ problemCode: "storage-cloudinary/validation-failed",
+ problemStatus: 422,
+ },
+ });
+ });
+
+ it("normalizes nested Cloudinary error payloads", () => {
+ const problem = normalizeCloudinaryStorageError(
+ {
+ error: {
+ code: "RATE_LIMITED",
+ http_code: 429,
+ message: "Too many requests",
+ },
+ },
+ { operation: "readiness" },
+ );
+
+ expect(getCloudinaryErrorMessage({ error: { message: "Too many requests" } }, "fallback")).toBe(
+ "Too many requests",
+ );
+ expect(problem).toMatchObject({
+ code: "storage-cloudinary/retryable-upstream",
+ extensions: {
+ provider: "cloudinary",
+ operation: "readiness",
+ status: 429,
+ upstreamCode: "RATE_LIMITED",
+ retryable: true,
+ },
+ });
+ });
+});
diff --git a/packages/storage-cloudinary/src/tests/CloudinaryLiveSmoke.spec.ts b/packages/storage-cloudinary/src/tests/CloudinaryLiveSmoke.spec.ts
new file mode 100644
index 000000000..9526b7c20
--- /dev/null
+++ b/packages/storage-cloudinary/src/tests/CloudinaryLiveSmoke.spec.ts
@@ -0,0 +1,63 @@
+import { v2 as cloudinary } from "cloudinary";
+import { describe, expect, it } from "vitest";
+import { CloudinaryDiagnosticsProvider } from "../libs/CloudinaryDiagnosticsProvider";
+import type { CloudinaryConfig } from "../libs/types";
+
+const CLOUDINARY_LIVE_ENV = [
+ "CROCO_LIVE_CLOUDINARY",
+ "CLOUDINARY_CLOUD_NAME",
+ "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET",
+] as const;
+
+const liveConfig: CloudinaryConfig = {
+ apiKey: process.env.CLOUDINARY_API_KEY ?? "",
+ apiSecret: process.env.CLOUDINARY_API_SECRET ?? "",
+ cloudName: process.env.CLOUDINARY_CLOUD_NAME ?? "",
+ secure: true,
+};
+
+const missingLiveSmokeEnv = [
+ ...(!isTruthyEnv("CROCO_LIVE_CLOUDINARY") ? ["CROCO_LIVE_CLOUDINARY"] : []),
+ ...CLOUDINARY_LIVE_ENV.filter((name) => name !== "CROCO_LIVE_CLOUDINARY" && !process.env[name]),
+];
+
+describe("Cloudinary live smoke", () => {
+ it.skipIf(missingLiveSmokeEnv.length > 0)(
+ "requires CROCO_LIVE_CLOUDINARY and Cloudinary credentials for live readiness smoke",
+ async () => {
+ const provider = new CloudinaryDiagnosticsProvider(liveConfig, {
+ readinessCheck: async ({ config }) => {
+ const response = await cloudinary.api.config({
+ api_key: config.apiKey,
+ api_secret: config.apiSecret,
+ cloud_name: config.cloudName,
+ secure: config.secure,
+ settings: false,
+ });
+
+ return {
+ details: {
+ cloudName: response.cloud_name ?? config.cloudName,
+ },
+ };
+ },
+ });
+
+ const health = await provider.getHealth();
+
+ expect(health).toMatchObject({
+ status: "healthy",
+ component: "storage-cloudinary",
+ details: expect.objectContaining({
+ liveCheck: "passed",
+ }),
+ });
+ },
+ );
+});
+
+function isTruthyEnv(name: string): boolean {
+ const value = process.env[name]?.trim().toLowerCase();
+ return value === "1" || value === "true" || value === "yes";
+}
diff --git a/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts b/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts
index d5b158b96..3e530302d 100644
--- a/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts
+++ b/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts
@@ -1,3 +1,4 @@
+import { PassThrough } from "node:stream";
import { Container } from "@croco/framework-context";
import type {
ObjectMetadata,
@@ -6,17 +7,19 @@ import type {
TransformOptions,
UploadIntent,
} from "@croco/storage-core";
-import {
- DeleteFailedProblem,
- FileNotFoundProblem,
- InvalidKeyProblem,
- UploadFailedProblem,
-} from "@croco/storage-core";
+import { FileNotFoundProblem, InvalidKeyProblem } from "@croco/storage-core";
+import { createStorageProviderConformanceSuite } from "@croco/testing";
import { v2 as cloudinary } from "cloudinary";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CloudinaryProvider } from "../libs/CloudinaryProvider";
type UploadStream = typeof cloudinary.uploader.upload_stream;
+type StoredCloudinaryObject = {
+ readonly context?: string;
+ readonly createdAt: string;
+ readonly data: Buffer;
+ readonly etag: string;
+};
// Cloudinary SDK 모킹
vi.mock("cloudinary", () => ({
@@ -53,6 +56,27 @@ describe("CloudinaryProvider", () => {
provider = new CloudinaryProvider(mockConfig);
});
+ describe("storage provider conformance", () => {
+ it.each(
+ createStorageProviderConformanceSuite({
+ createProvider: () => {
+ useInMemoryCloudinaryBackend();
+ return provider;
+ },
+ keyPrefix: "cloudinary-conformance",
+ metadata: {
+ contentType: "unsupported",
+ customMetadata: "required",
+ },
+ providerName: "storage-cloudinary",
+ publicUrl: "https://res.cloudinary.com/test-cloud/image/upload/",
+ signedUrl: /s=mock-signature/,
+ }).cases,
+ )("$name", async ({ run }) => {
+ await run();
+ });
+ });
+
it("should not mutate global cloudinary config during construction", () => {
expect(cloudinary.config).not.toHaveBeenCalled();
});
@@ -242,7 +266,7 @@ describe("CloudinaryProvider", () => {
);
});
- it("should throw UploadFailedProblem on upload error", async () => {
+ it("should throw terminal provider Problem on upload error", async () => {
const mockError = new Error("Upload failed");
const mockUploadStream = vi.fn(
(_options: unknown, callback: (error: Error | undefined, result: unknown) => void) => {
@@ -259,7 +283,9 @@ describe("CloudinaryProvider", () => {
const buffer = Buffer.from("test data");
- await expect(provider.put("test-key", buffer)).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.put("test-key", buffer)).rejects.toMatchObject({
+ code: "storage-cloudinary/terminal-upstream",
+ });
});
it("should retry transient upload errors before succeeding", async () => {
@@ -317,17 +343,17 @@ describe("CloudinaryProvider", () => {
expect(cloudinary.uploader.upload_stream).toHaveBeenCalledTimes(3);
});
- it("should throw UploadFailedProblem when upload stream creation throws", async () => {
+ it("should throw terminal provider Problem when upload stream creation throws", async () => {
vi.mocked(cloudinary.uploader.upload_stream).mockImplementation(() => {
throw new Error("Cloudinary SDK error");
});
- await expect(provider.put("test-key", Buffer.from("test data"))).rejects.toThrow(
- UploadFailedProblem,
- );
+ await expect(provider.put("test-key", Buffer.from("test data"))).rejects.toMatchObject({
+ code: "storage-cloudinary/terminal-upstream",
+ });
});
- it("should throw UploadFailedProblem when source stream emits error", async () => {
+ it("should throw terminal provider Problem when source stream emits error", async () => {
const { PassThrough } = await import("node:stream");
const destination = new PassThrough();
@@ -340,7 +366,9 @@ describe("CloudinaryProvider", () => {
source.emit("error", new Error("Stream broken"));
- await expect(putPromise).rejects.toThrow(UploadFailedProblem);
+ await expect(putPromise).rejects.toMatchObject({
+ code: "storage-cloudinary/terminal-upstream",
+ });
});
it("should not retry readable stream uploads on transient callback errors", async () => {
@@ -367,7 +395,9 @@ describe("CloudinaryProvider", () => {
await expect(
provider.put("test-key", Readable.from(Buffer.from("test data"))),
- ).rejects.toThrow(UploadFailedProblem);
+ ).rejects.toMatchObject({
+ code: "storage-cloudinary/retryable-upstream",
+ });
expect(cloudinary.uploader.upload_stream).toHaveBeenCalledTimes(1);
});
@@ -424,7 +454,7 @@ describe("CloudinaryProvider", () => {
await expect(provider.get("test-key")).rejects.toThrow(FileNotFoundProblem);
});
- it("should throw UploadFailedProblem on non-404 HTTP error", async () => {
+ it("should throw retryable provider Problem on retryable HTTP error", async () => {
const mockResponse = {
ok: false,
status: 500,
@@ -432,13 +462,17 @@ describe("CloudinaryProvider", () => {
vi.mocked(global.fetch).mockResolvedValue(mockResponse as unknown as Response);
- await expect(provider.get("test-key")).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.get("test-key")).rejects.toMatchObject({
+ code: "storage-cloudinary/retryable-upstream",
+ });
});
- it("should throw UploadFailedProblem on fetch error", async () => {
+ it("should throw retryable provider Problem on fetch error", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
- await expect(provider.get("test-key")).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.get("test-key")).rejects.toMatchObject({
+ code: "storage-cloudinary/retryable-upstream",
+ });
});
it("should retry transient fetch failures before succeeding", async () => {
@@ -500,10 +534,12 @@ describe("CloudinaryProvider", () => {
await expect(provider.delete("test-key")).resolves.not.toThrow();
});
- it("should throw DeleteFailedProblem on delete failure", async () => {
+ it("should throw terminal provider Problem on delete failure", async () => {
vi.mocked(cloudinary.uploader.destroy).mockResolvedValue({ result: "error" });
- await expect(provider.delete("test-key")).rejects.toThrow(DeleteFailedProblem);
+ await expect(provider.delete("test-key")).rejects.toMatchObject({
+ code: "storage-cloudinary/terminal-upstream",
+ });
});
it("should retry transient delete failures before succeeding", async () => {
@@ -683,13 +719,15 @@ describe("CloudinaryProvider", () => {
await expect(provider.getMetadata("test-key")).rejects.toThrow(FileNotFoundProblem);
});
- it("should throw UploadFailedProblem for non-404 metadata errors", async () => {
+ it("should throw validation provider Problem for non-404 metadata validation errors", async () => {
vi.mocked(cloudinary.api.resource).mockRejectedValue({
http_code: 403,
message: "Forbidden",
});
- await expect(provider.getMetadata("test-key")).rejects.toThrow(UploadFailedProblem);
+ await expect(provider.getMetadata("test-key")).rejects.toMatchObject({
+ code: "storage-cloudinary/validation-failed",
+ });
});
it("should retry transient metadata failures before succeeding", async () => {
@@ -1091,3 +1129,114 @@ describe("CloudinaryProvider", () => {
});
});
});
+
+function useInMemoryCloudinaryBackend(): void {
+ const objects = new Map();
+
+ vi.mocked(cloudinary.uploader.upload_stream).mockImplementation(((
+ options: unknown,
+ callback: (error: Error | undefined, result: unknown) => void,
+ ) => {
+ const uploadOptions = options as {
+ readonly context?: string;
+ readonly public_id?: string;
+ };
+ const destination = new PassThrough();
+ const chunks: Buffer[] = [];
+
+ destination.on("data", (chunk: Buffer | Uint8Array | string) => {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ });
+ destination.on("error", (error) => callback(error, undefined));
+ destination.on("finish", () => {
+ if (!uploadOptions.public_id) {
+ callback(new Error("public_id is required"), undefined);
+ return;
+ }
+
+ objects.set(uploadOptions.public_id, {
+ context: uploadOptions.context,
+ createdAt: "2026-01-01T00:00:00Z",
+ data: Buffer.concat(chunks),
+ etag: `${uploadOptions.public_id}:etag`,
+ });
+ callback(undefined, { public_id: uploadOptions.public_id });
+ });
+
+ return destination;
+ }) as unknown as UploadStream);
+
+ vi.mocked(cloudinary.uploader.destroy).mockImplementation(async (key: string) => {
+ const existed = objects.delete(key);
+ return { result: existed ? "ok" : "not found" };
+ });
+
+ vi.mocked(cloudinary.api.resource).mockImplementation(async (key: string) => {
+ const object = objects.get(key);
+ if (!object) {
+ throw { http_code: 404, message: "Resource not found" };
+ }
+
+ return {
+ bytes: object.data.length,
+ context: object.context,
+ created_at: object.createdAt,
+ etag: object.etag,
+ };
+ });
+
+ vi.mocked(cloudinary.url).mockImplementation((key: string, options?: unknown) => {
+ const optionRecord = typeof options === "object" && options !== null ? options : undefined;
+ const cloudNameValue = optionRecord ? Reflect.get(optionRecord, "cloud_name") : undefined;
+ const secureValue = optionRecord ? Reflect.get(optionRecord, "secure") : undefined;
+ const transformationValue = optionRecord
+ ? Reflect.get(optionRecord, "transformation")
+ : undefined;
+ const signUrlValue = optionRecord ? Reflect.get(optionRecord, "sign_url") : undefined;
+ const cloudName = typeof cloudNameValue === "string" ? cloudNameValue : "test-cloud";
+ const protocol = secureValue === false ? "http" : "https";
+ const transformation =
+ typeof transformationValue === "string" && transformationValue.length > 0
+ ? `${transformationValue}/`
+ : "";
+ const query = signUrlValue ? "?expires=60&s=mock-signature" : "";
+
+ return `${protocol}://res.cloudinary.com/${cloudName}/image/upload/${transformation}${key}${query}`;
+ });
+
+ vi.mocked(global.fetch).mockImplementation(async (input: string | URL | Request) => {
+ const key = parseCloudinaryDeliveryKey(String(input), "test-cloud");
+ if (!key) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const object = objects.get(key);
+ if (!object) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ return new Response(new Uint8Array(object.data));
+ });
+}
+
+function parseCloudinaryDeliveryKey(url: string, cloudName: string): string | null {
+ const parsed = new URL(url);
+ const marker = `/${cloudName}/image/upload/`;
+ const markerIndex = parsed.pathname.indexOf(marker);
+
+ if (markerIndex === -1) {
+ return null;
+ }
+
+ const rawKey = parsed.pathname.slice(markerIndex + marker.length);
+ if (rawKey.length === 0) {
+ return null;
+ }
+
+ const segments = rawKey.split("/");
+ if (segments[0]?.includes("_") && segments.length > 1) {
+ segments.shift();
+ }
+
+ return segments.map(decodeURIComponent).join("/");
+}
diff --git a/packages/storage-cloudinary/tsconfig.json b/packages/storage-cloudinary/tsconfig.json
index a39dbd865..d785efdeb 100644
--- a/packages/storage-cloudinary/tsconfig.json
+++ b/packages/storage-cloudinary/tsconfig.json
@@ -1,5 +1,11 @@
{
"exclude": ["node_modules"],
"extends": "../../tsconfig/tsconfig.node.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@croco/testing": ["../testing/src/libs/provider-conformance.ts"]
+ }
+ },
"include": ["src/**/*.ts"]
}
diff --git a/packages/storage-cloudinary/vitest.config.ts b/packages/storage-cloudinary/vitest.config.ts
index 12445dcb0..854bab5e2 100644
--- a/packages/storage-cloudinary/vitest.config.ts
+++ b/packages/storage-cloudinary/vitest.config.ts
@@ -1,6 +1,21 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
+const currentDir = dirname(fileURLToPath(import.meta.url));
+
export default defineConfig({
+ resolve: {
+ alias: {
+ "@croco/diagnostics-core": resolve(currentDir, "../diagnostics-core/src/index.ts"),
+ "@croco/framework-context": resolve(currentDir, "../framework-context/src/index.ts"),
+ "@croco/problems-core": resolve(currentDir, "../problems-core/src/index.ts"),
+ "@croco/retry-core": resolve(currentDir, "../retry-core/src/index.ts"),
+ "@croco/storage-core": resolve(currentDir, "../storage-core/src/index.ts"),
+ "@croco/telemetry-api": resolve(currentDir, "../telemetry-api/src/index.ts"),
+ "@croco/testing": resolve(currentDir, "../testing/src/libs/provider-conformance.ts"),
+ },
+ },
test: {
globals: true,
environment: "node",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc49951e9..d6c57e769 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1041,6 +1041,12 @@ importers:
'@croco/search-core':
specifier: workspace:*
version: link:../search-core
+ '@croco/storage-cloudflare':
+ specifier: workspace:*
+ version: link:../storage-cloudflare
+ '@croco/storage-cloudinary':
+ specifier: workspace:*
+ version: link:../storage-cloudinary
'@croco/storage-core':
specifier: workspace:*
version: link:../storage-core
@@ -2530,6 +2536,9 @@ importers:
packages/storage-cloudflare:
dependencies:
+ '@croco/diagnostics-core':
+ specifier: workspace:*
+ version: link:../diagnostics-core
'@croco/framework-context':
specifier: workspace:*
version: link:../framework-context
@@ -2546,6 +2555,9 @@ importers:
packages/storage-cloudinary:
dependencies:
+ '@croco/diagnostics-core':
+ specifier: workspace:*
+ version: link:../diagnostics-core
'@croco/framework-context':
specifier: workspace:*
version: link:../framework-context
@@ -2561,6 +2573,10 @@ importers:
cloudinary:
specifier: ^2.10.0
version: 2.10.0
+ devDependencies:
+ '@croco/testing':
+ specifier: workspace:*
+ version: link:../testing
packages/storage-core:
dependencies:
diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json
index e3b8f6f3a..5181614be 100644
--- a/public-api-surface.snapshot.json
+++ b/public-api-surface.snapshot.json
@@ -15122,11 +15122,59 @@
"source": "./libs/tokens",
"declarationKind": "const"
},
+ {
+ "name": "CloudflareImagesDiagnosticsProvider",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "CloudflareImagesMissingConfigProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "class"
+ },
{
"name": "CloudflareImagesProvider",
"exportKind": "named",
"source": "./libs/CloudflareImagesProvider",
"declarationKind": "class"
+ },
+ {
+ "name": "CloudflareImagesRetryableUpstreamProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "CloudflareImagesTerminalUpstreamProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "CloudflareImagesValidationProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "createCloudflareImagesResponseProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "function"
+ },
+ {
+ "name": "normalizeCloudflareImagesError",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "function"
+ },
+ {
+ "name": "validateCloudflareImagesOptions",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider",
+ "declarationKind": "function"
}
],
"typeExports": [
@@ -15135,11 +15183,36 @@
"exportKind": "named",
"source": "./libs/types"
},
+ {
+ "name": "CloudflareImagesConfigKey",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider"
+ },
+ {
+ "name": "CloudflareImagesDiagnosticsOptions",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider"
+ },
+ {
+ "name": "CloudflareImagesErrorContext",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider"
+ },
{
"name": "CloudflareImagesOptions",
"exportKind": "named",
"source": "./libs/types"
},
+ {
+ "name": "CloudflareImagesReadinessCheckContext",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider"
+ },
+ {
+ "name": "CloudflareImagesReadinessCheckResult",
+ "exportKind": "named",
+ "source": "./libs/CloudflareImagesDiagnosticsProvider"
+ },
{
"name": "CloudflareTransformOptions",
"exportKind": "named",
@@ -15163,11 +15236,65 @@
"source": "./libs/tokens",
"declarationKind": "const"
},
+ {
+ "name": "CloudinaryDiagnosticsProvider",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "CloudinaryMissingConfigProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "class"
+ },
{
"name": "CloudinaryProvider",
"exportKind": "named",
"source": "./libs/CloudinaryProvider",
"declarationKind": "class"
+ },
+ {
+ "name": "CloudinaryRetryableUpstreamProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "CloudinaryTerminalUpstreamProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "CloudinaryValidationProblem",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "class"
+ },
+ {
+ "name": "getCloudinaryErrorMessage",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "function"
+ },
+ {
+ "name": "isRetryableCloudinaryStorageError",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "function"
+ },
+ {
+ "name": "normalizeCloudinaryStorageError",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "function"
+ },
+ {
+ "name": "validateCloudinaryConfig",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider",
+ "declarationKind": "function"
}
],
"typeExports": [
@@ -15176,6 +15303,31 @@
"exportKind": "named",
"source": "./libs/types"
},
+ {
+ "name": "CloudinaryConfigKey",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider"
+ },
+ {
+ "name": "CloudinaryDiagnosticsOptions",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider"
+ },
+ {
+ "name": "CloudinaryReadinessCheckContext",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider"
+ },
+ {
+ "name": "CloudinaryReadinessCheckResult",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider"
+ },
+ {
+ "name": "CloudinaryStorageErrorContext",
+ "exportKind": "named",
+ "source": "./libs/CloudinaryDiagnosticsProvider"
+ },
{
"name": "CloudinaryTransformOptions",
"exportKind": "named",
diff --git a/scripts/package-docs-check.mts b/scripts/package-docs-check.mts
index d5997523b..6426d15c3 100644
--- a/scripts/package-docs-check.mts
+++ b/scripts/package-docs-check.mts
@@ -1357,7 +1357,7 @@ function generateReadmeCatalog(state: CatalogState): string {
"",
"Adapter 경계와 공식 우선순위, compatibility certification checklist는 [Adapter Ecosystem](packages/docs/src/content/docs/en/reference/adapter-ecosystem.md)에 정의되어 있습니다. 성숙도 승급 기준은 [Provider Maturity Gates](packages/docs/src/content/docs/en/reference/provider-maturity.md)와 [Presentation Runtime Support](packages/docs/src/content/docs/en/reference/presentation-runtime-support.md)에 정의되어 있으며, package test 존재 여부만으로 production-ready나 certified compatibility를 의미하지 않습니다.",
"",
- "| 상태 | 의미 | 패키지 수 |",
+ "| 상태 | 의미 | 전체 public 패키지 수 |",
"| --- | --- | ---: |",
);