Skip to main content
Version: v0.25.0 (Latest)

Blob Store

The IDK provides a blob storage abstraction for managing binary data: documents, images, certificates, credential payloads, or any other file-like content. The BlobStore interface offers a consistent API across backends (in-memory, filesystem, KV-backed, HTTP remote), with support for content-addressable storage, metadata indexing, temporary URLs, and tenant isolation.

Architecture

The blob storage system has two main layers:

  • BlobStore: Low-level interface for individual storage backends. Each backend implements this directly.
  • BlobService: High-level service that manages multiple stores, routes operations to the right backend, and adds content-addressable storage (CAS) and metadata search on top.

In most cases you should use BlobService rather than calling BlobStore directly. The service layer handles backend routing, CAS indexing, and metadata search for you. You only need to drop down to BlobStore if you are implementing a custom backend or need fine-grained control over a specific storage driver.

Both are session-scoped. The BlobService is the primary entry point for application code.

Core Concepts

Blob Info

Every blob operation uses a BlobInfo to identify the target:

val info = BlobInfo(
storeId = "documents", // Which store to use
path = "credentials/mdl.cbor", // Path within the store
tenantId = "acme-corp", // Tenant isolation (optional)
contentType = "application/cbor",
metadata = mapOf("issuer" to "gov.example.com")
)

The path follows filesystem-like conventions with / as delimiter. Paths are validated and sanitized to prevent directory traversal attacks.

Blob Descriptor

After storing a blob, you get a BlobDescriptor with metadata about the stored object:

data class BlobDescriptor(
val path: String,
val storeId: String,
val sizeBytes: Long,
val contentType: String?,
val filename: String?,
val etag: String?,
val createdAt: Instant?,
val lastModified: Instant?,
val metadata: BlobMetadata,
val contentHash: String?
)

Resolved Blob Info

When you read a blob, you get a ResolvedBlobInfo that includes the descriptor and the actual data:

val resolved: ResolvedBlobInfo = blobService.getBlob(info).getOrThrow()
val bytes: ByteArray = resolved.data
val size: Long = resolved.sizeBytes
val descriptor: BlobDescriptor = resolved.descriptor

Basic Operations

val blobService = session.graph.blobService

// Store a blob
val info = BlobInfo.of(storeId = "documents", path = "credentials/mdl.cbor")
val descriptor = blobService.storeBlob(
target = info.copy(contentType = "application/cbor"),
data = cborBytes
).getOrThrow()

println("Stored ${descriptor.sizeBytes} bytes at ${descriptor.path}")

// Retrieve a blob
val resolved = blobService.getBlob(info).getOrThrow()
val data = resolved.data

// Check existence
val exists = blobService.getBlobInfo(info).isOk

// Delete a blob
blobService.deleteBlob(info).getOrThrow()

Listing and Pagination

List blobs with prefix filtering and pagination:

val blobService = session.graph.blobService

// List all credentials
val result = blobService.listBlobs(
info = BlobInfo.of("documents", "credentials/"),
options = ListOptions(
prefix = "credentials/",
delimiter = "/",
maxResults = 50,
recursive = false
)
).getOrThrow()

for (descriptor in result.descriptors) {
println("${descriptor.path} (${descriptor.sizeBytes} bytes)")
}

// Paginate if there are more results
if (result.hasMore) {
val nextPage = blobService.listBlobs(
info = BlobInfo.of("documents", "credentials/"),
options = ListOptions(pageToken = result.nextPageToken)
).getOrThrow()
}

Copy and Move

val source = BlobInfo.of("documents", "inbox/new-credential.cbor")
val destination = BlobInfo.of("documents", "credentials/verified/mdl.cbor")

// Copy (keeps the original)
blobService.copyBlob(source, destination).getOrThrow()

// Move (removes the original)
blobService.moveBlob(source, destination).getOrThrow()

Not all backends support copy and move, so check capabilities.supportsCopy and capabilities.supportsMove before calling.

Content-Addressable Storage (CAS)

CAS stores blobs by their content hash rather than a path. This gives you two things for free: deduplication (storing identical content twice returns the same address without writing a second copy) and integrity verification (you can re-hash the data at any time and compare it to the address). CAS is a good fit for credential payloads and other content where you want to detect tampering or avoid storing duplicates.

val blobService = session.graph.blobService

// Store by content hash
val casResult = blobService.casStore(
info = BlobInfo.of("cas-store", ""),
data = credentialBytes,
algorithm = DigestAlg.SHA256
).getOrThrow()

val address: ContentAddress = casResult.address
println("Stored at: ${address.toDigestString()}") // "sha256:a1b2c3..."

// Retrieve by address
val resolved = blobService.casGet(
info = BlobInfo.of("cas-store", ""),
address = address
).getOrThrow()

// Verify integrity
val valid = blobService.casVerify(
info = BlobInfo.of("cas-store", ""),
address = address
).getOrThrow()

Content addresses can be encoded as multibase strings or hashlinks for interoperability:

val multibase = address.toMultibaseString()  // "z..." (base58btc)
val hashlink = address.toHashlink() // "hl:z..."
val digestStr = address.toDigestString() // "sha256:a1b2c3..."

// Parse back
val parsed = ContentAddress.fromDigestString("sha256:a1b2c3...")

Blobs carry structured metadata for classification and search:

val info = BlobInfo(
storeId = "documents",
path = "credentials/mdl.cbor",
contentType = "application/cbor",
metadata = mapOf(
"credentialType" to "mDL",
"issuer" to "gov.example.com",
"issuedAt" to "2025-01-15"
)
)

blobService.storeBlob(target = info, data = cborBytes)

// Search by metadata
val results = blobService.findByMetadata(
info = BlobInfo.of("documents", ""),
query = MetadataSearchQuery(
contentType = "application/cbor",
customMetadata = mapOf("credentialType" to "mDL"),
pathPrefix = "credentials/",
maxResults = 100
)
).getOrThrow()

for (descriptor in results) {
println("Found: ${descriptor.path}")
}

Temporary URLs

Temporary URLs let you hand out a short-lived, pre-signed link that grants direct access to a blob. This is useful when a client needs to download or display a file (such as a credential image or document) without routing every byte through your application server. The URL expires after the configured duration, so you do not have to worry about revoking access manually.

val tempUrl = blobService.createTempUrl(
info = BlobInfo.of("documents", "credentials/mdl.cbor"),
options = TempUrlOptions(
expiresIn = 1.hours,
method = TempUrlMethod.GET,
contentDisposition = "attachment; filename=\"mdl.cbor\""
)
).getOrThrow()

println("Download URL: ${tempUrl.url}")
println("Expires at: ${tempUrl.expiresAt}")

Check capabilities.supportsTempUrls before using this feature.

Store Capabilities

Each backend declares what operations it supports:

data class BlobStoreCapabilities(
val supportsEtag: Boolean,
val supportsCopy: Boolean,
val supportsMove: Boolean,
val supportsFolders: Boolean,
val supportsBulkDelete: Boolean,
val supportsTempUrls: Boolean,
val supportsListing: Boolean,
val supportsMetadata: Boolean,
val maxBlobSizeBytes: Long,
val maxTempUrlDuration: Duration?
)

Always check capabilities before using optional operations to keep your code portable across backends.

Put Options

Control overwrite behavior and integrity checking:

// Prevent overwriting existing blobs
blobService.storeBlob(
target = info,
data = bytes,
options = PutOptions(overwrite = false)
)

// Compute content hash on store
blobService.storeBlob(
target = info,
data = bytes,
options = PutOptions(digestAlgorithm = DigestAlg.SHA256)
)

Scope Binding

Scope binding controls how blob paths are partitioned. In a multi-tenant deployment, TENANT scoping means each tenant's blobs live in an isolated partition, so path collisions between tenants are impossible and access control is enforced at the storage layer.

ScopeBehavior
APPShared across all tenants, serving as global storage
TENANTIsolated per tenant; each tenant has its own storage partition

Scope binding is configured when the store is created and determines how paths are partitioned.

Storage Backends

Choose a backend based on where your data needs to live. In-memory is good for tests and short-lived caches. Filesystem works for server-side deployments where you have local disk. KV-backed is convenient when you already have a KvStore set up and want to avoid introducing another storage dependency. The HTTP client backend lets mobile or edge apps access a remote blob service over the network.

In-Memory

Stores blobs in memory. Data is lost when the application exits. Useful for testing and development.

build.gradle.kts
dependencies {
implementation("com.sphereon.idk:lib-data-store-blob-impl-memory:0.25.0")
}

Filesystem

Stores blobs as files on disk. Supports directory-based organization, auto-creates directories, and works on JVM and native targets.

build.gradle.kts
dependencies {
implementation("com.sphereon.idk:lib-data-store-blob-impl-fs:0.25.0")
}

Configuration:

blob.stores.documents.backend=filesystem
blob.stores.documents.root-dir=/var/data/blobs
blob.stores.documents.auto-create-dirs=true
blob.stores.documents.scope-binding=TENANT

KV-Backed

Uses an existing KvStore as the blob backend. Convenient when you already have a KV store configured and don't want a separate blob backend.

build.gradle.kts
dependencies {
implementation("com.sphereon.idk:lib-data-store-blob-impl-kv:0.25.0")
}

HTTP Client

Accesses blobs from a remote HTTP endpoint. Useful for accessing centralized blob storage from client applications.

build.gradle.kts
dependencies {
implementation("com.sphereon.idk:lib-data-store-blob-client-http:0.25.0")
}

Error Handling

Blob operations return IdkResult and throw typed BlobStoreError exceptions:

ErrorWhen
NotFoundBlob does not exist at the given path
AlreadyExistsPutOptions(overwrite = false) and the path is taken
IoErrorFilesystem or network failure
UnsupportedBackend doesn't support the requested operation
QuotaExceededStorage limit reached
IntegrityErrorContent hash mismatch
PermissionDeniedAccess control violation
PreconditionFailedETag or if-none-match condition not met
val result = blobService.getBlob(info)
when {
result.isOk -> {
val data = result.getOrThrow().data
}
result.isErr -> {
val error = result.unwrapErr()
println("Failed: ${error.message}")
}
}

Module Dependencies

build.gradle.kts
dependencies {
// Public API (always needed)
implementation("com.sphereon.idk:lib-data-store-blob-public:0.25.0")
implementation("com.sphereon.idk:lib-data-store-blob-impl:0.25.0")

// Include one or more backends
implementation("com.sphereon.idk:lib-data-store-blob-impl-memory:0.25.0") // In-memory
implementation("com.sphereon.idk:lib-data-store-blob-impl-fs:0.25.0") // Filesystem
implementation("com.sphereon.idk:lib-data-store-blob-impl-kv:0.25.0") // KV-backed
implementation("com.sphereon.idk:lib-data-store-blob-client-http:0.25.0") // HTTP remote
}