Storage
sdk.storage gives every app three isolated storage primitives — blobs, structured documents, and ephemeral key-value — without holding any database credentials. All operations flow through cpod-backend, which enforces isolation from the JWT claims and routes to the correct backing store.
Your App → sdk.storage.* → REST → cpod-backend
│
extracts from JWT:
tenantId (hard partition)
appId (stamped from client_id)
userId (for user-tier ops)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
MinIO MongoDB Redis
(files) (db) (kv)
No DB credentials ever reach the SDK. The tenantId, appId, and userId that determine where data lives are extracted server-side from the Bearer token — the client cannot spoof them.
Storage Tiers#
Every operation targets one of three tiers. The tier determines who can read the data — not which physical store it uses.
| Tier | Scope | Required scope |
|---|---|---|
private (default) | This app only — other apps cannot read it | storage.private.read / storage.private.write |
user | The authenticated user, visible to all apps they use | storage.user.read / storage.user.write |
shared | All apps within the same tenant | storage.shared.read / storage.shared.write |
Declare the scopes your app needs at registration time. Tokens cannot grant scopes not declared.
sdk.storage.files — Blob Storage#
Backed by MinIO/S3. Use this for PDFs, images, exports, raw data files — anything binary or large.
Write#
import { CpodClient } from '@cpod/sdk'
const sdk = CpodClient.fromEnv()
const data = Buffer.from('{"hello":"world"}')
// Private (default) — only this app can read it
const result = await sdk.storage.files.write('config/settings.json', data, {
contentType: 'application/json',
})
// result.key → "config/settings.json"
// result.tier → "private"
// User-scoped — any app this user uses can read it
await sdk.storage.files.write('avatar.png', imageBuffer, {
contentType: 'image/png',
tier: 'user',
})
// Tenant shared — all apps in this tenant can read it
await sdk.storage.files.write('brand/logo.svg', svgBuffer, {
contentType: 'image/svg+xml',
tier: 'shared',
})from cpod import CpodClient
sdk = CpodClient(api_key="...")
data = b'{"hello":"world"}'
result = await sdk.storage.files.write(
"config/settings.json",
data,
content_type="application/json",
)
# result.key → "config/settings.json"
# result.tier → "private"
await sdk.storage.files.write(
"avatar.png", image_bytes, content_type="image/png", tier="user"
)client := cpod.New(os.Getenv("CPOD_API_KEY"))
data := []byte(`{"hello":"world"}`)
result, err := client.Storage.Files.Write(
ctx, "config/settings.json", data, "application/json", storage.TierPrivate,
)
// result.Key → "config/settings.json"
// result.Tier → "private"var client = CpodClient.FromEnv();
var data = Encoding.UTF8.GetBytes("{\"hello\":\"world\"}");
// Private (default) — only this app can read it
var result = await client.Storage.Files.WriteAsync(
"config/settings.json", data, contentType: "application/json");
// result.Key → "config/settings.json"
// result.Tier → "private"
// User-scoped — any app this user uses can read it
await client.Storage.Files.WriteAsync(
"avatar.png", imageBytes, contentType: "image/png", tier: StorageTier.User);
// Tenant shared — all apps in this tenant can read it
await client.Storage.Files.WriteAsync(
"brand/logo.svg", svgBytes, contentType: "image/svg+xml", tier: StorageTier.Shared);Read#
const stream = await sdk.storage.files.read('config/settings.json')
const text = await new Response(stream).text()contents = await sdk.storage.files.read("config/settings.json")
# contents is bytescontents, err := client.Storage.Files.Read(ctx, "config/settings.json", storage.TierPrivate)var contents = await client.Storage.Files.ReadAsync("config/settings.json");
// contents is byte[]Presigned URL#
Generate a time-limited public URL for downloads — share with end users or pass to a browser without exposing credentials.
const url = await sdk.storage.files.url('exports/report.pdf', {
expiresIn: 3600, // seconds, default 3600
tier: 'private',
})
// Send url to the user's browser — no auth header needed to downloadurl = await sdk.storage.files.url(
"exports/report.pdf", expires_in=3600
)url, err := client.Storage.Files.URL(ctx, "exports/report.pdf", 3600, storage.TierPrivate)var url = await client.Storage.Files.UrlAsync(
"exports/report.pdf", expiresIn: 3600, tier: StorageTier.Private);
// Send url to the user's browser — no auth header needed to downloadList & Delete#
const listing = await sdk.storage.files.list('exports/', { limit: 50 })
for (const entry of listing.items) {
console.log(entry.key, entry.size, entry.lastModified)
}
await sdk.storage.files.delete('exports/old-report.pdf')listing = await sdk.storage.files.list("exports/", limit=50)
for entry in listing.items:
print(entry.key, entry.size)
await sdk.storage.files.delete("exports/old-report.pdf")listing, err := client.Storage.Files.List(ctx, "exports/", storage.TierPrivate, 50, "")
for _, entry := range listing.Items {
fmt.Println(entry.Key, entry.Size)
}
err = client.Storage.Files.Delete(ctx, "exports/old-report.pdf", storage.TierPrivate)var listing = await client.Storage.Files.ListAsync("exports/", limit: 50);
foreach (var entry in listing.Items)
{
Console.WriteLine($"{entry.Key} {entry.Size} {entry.LastModified}");
}
await client.Storage.Files.DeleteAsync("exports/old-report.pdf");sdk.storage.db — Structured Document Storage#
Backed by MongoDB. Use this for structured app state, user preferences, cached records, per-user configuration — anything that needs filtering or batch operations.
You work with named collections and string keys. No MongoDB query language is exposed — the interface is intentionally constrained so the backing store can be swapped.
Set & Get#
const prefs = sdk.storage.db.collection('preferences')
// Write
await prefs.set('theme', { mode: 'dark', accent: 'purple', fontSize: 14 })
// Read
const theme = await prefs.get('theme')
// → { mode: 'dark', accent: 'purple', fontSize: 14 }
// Not found → null
const missing = await prefs.get('does-not-exist')
// → null
// Per-user preferences (user tier)
const userPrefs = sdk.storage.db.collection('preferences', { tier: 'user' })
await userPrefs.set('layout', { sidebar: 'collapsed' })prefs = sdk.storage.db.collection("preferences")
await prefs.set("theme", {"mode": "dark", "accent": "purple", "fontSize": 14})
theme = await prefs.get("theme")
# → {"mode": "dark", "accent": "purple", "fontSize": 14}
# Not found → None
missing = await prefs.get("does-not-exist")
# → None
user_prefs = sdk.storage.db.collection("preferences", tier="user")
await user_prefs.set("layout", {"sidebar": "collapsed"})prefs := client.Storage.Db.Collection("preferences", storage.TierPrivate)
err = prefs.Set(ctx, "theme", map[string]any{
"mode": "dark", "accent": "purple", "fontSize": 14,
}, 0)
value, err := prefs.Get(ctx, "theme")
// value → map[string]any{"mode": "dark", ...}
// not found → nil, nilvar prefs = client.Storage.Db.Collection("preferences");
// Write
await prefs.SetAsync("theme", new { mode = "dark", accent = "purple", fontSize = 14 });
// Read
var theme = await prefs.GetAsync("theme");
// → { mode = "dark", accent = "purple", fontSize = 14 }
// Not found → null
var missing = await prefs.GetAsync("does-not-exist");
// → null
// Per-user preferences (user tier)
var userPrefs = client.Storage.Db.Collection("preferences", tier: StorageTier.User);
await userPrefs.SetAsync("layout", new { sidebar = "collapsed" });Query#
const sessions = sdk.storage.db.collection('sessions')
const active = await sessions.query({
filter: { status: 'active', plan: 'pro' },
sort: { createdAt: -1 },
limit: 20,
})
for (const item of active.items) {
console.log(item.key, item.value)
}
// Paginate
if (active.nextCursor) {
const page2 = await sessions.query({
filter: { status: 'active' },
cursor: active.nextCursor,
limit: 20,
})
}sessions = sdk.storage.db.collection("sessions")
active = await sessions.query(
filter={"status": "active", "plan": "pro"},
sort={"created_at": -1},
limit=20,
)
for item in active.items:
print(item.key, item.value)sessions := client.Storage.Db.Collection("sessions", storage.TierPrivate)
result, err := sessions.Query(ctx, storage.QueryOptions{
Filter: map[string]any{"status": "active"},
Sort: map[string]int{"createdAt": -1},
Limit: 20,
})
for _, item := range result.Items {
fmt.Println(item.Key, item.Value)
}var sessions = client.Storage.Db.Collection("sessions");
var active = await sessions.QueryAsync(new QueryOptions
{
Filter = new { status = "active", plan = "pro" },
Sort = new Dictionary<string, int> { ["createdAt"] = -1 },
Limit = 20,
});
foreach (var item in active.Items)
{
Console.WriteLine($"{item.Key} {item.Value}");
}
// Paginate
if (active.NextCursor is not null)
{
var page2 = await sessions.QueryAsync(new QueryOptions
{
Filter = new { status = "active" },
Cursor = active.NextCursor,
Limit = 20,
});
}Batch#
const cache = sdk.storage.db.collection('cache')
const result = await cache.batch([
{ op: 'set', key: 'user:123', value: { name: 'Alice', plan: 'pro' } },
{ op: 'set', key: 'user:456', value: { name: 'Bob', plan: 'free' } },
{ op: 'delete', key: 'user:999' },
])
// result.succeeded → 3
// result.failed → 0from cpod.storage import BatchOp
cache = sdk.storage.db.collection("cache")
result = await cache.batch([
BatchOp(op="set", key="user:123", value={"name": "Alice", "plan": "pro"}),
BatchOp(op="set", key="user:456", value={"name": "Bob", "plan": "free"}),
BatchOp(op="delete", key="user:999"),
])cache := client.Storage.Db.Collection("cache", storage.TierPrivate)
result, err := cache.Batch(ctx, []storage.BatchOp{
{Op: storage.BatchSet, Key: "user:123", Value: map[string]any{"name": "Alice"}},
{Op: storage.BatchSet, Key: "user:456", Value: map[string]any{"name": "Bob"}},
{Op: storage.BatchDelete, Key: "user:999"},
})var cache = client.Storage.Db.Collection("cache");
var result = await cache.BatchAsync(new[]
{
new BatchOp { Op = BatchOpType.Set, Key = "user:123", Value = new { name = "Alice", plan = "pro" } },
new BatchOp { Op = BatchOpType.Set, Key = "user:456", Value = new { name = "Bob", plan = "free" } },
new BatchOp { Op = BatchOpType.Delete, Key = "user:999" },
});
// result.Succeeded → 3
// result.Failed → 0sdk.storage.kv — Ephemeral Key-Value#
Backed by Redis. Use this for sessions, locks, rate limit counters, temporary tokens — anything with a TTL that doesn't need querying.
const kv = sdk.storage.kv
// Set with TTL (seconds)
await kv.set('session:abc123', { userId: 'usr-xxx', plan: 'pro' }, { ttl: 3600 })
// Get
const session = await kv.get('session:abc123')
// → '{"userId":"usr-xxx","plan":"pro"}' (always a string — parse if needed)
// Exists check
const alive = await kv.exists('session:abc123') // → true
// Atomic counter
await kv.set('counter:api-calls', '0')
const count = await kv.incr('counter:api-calls') // → 1
const count2 = await kv.incr('counter:api-calls', 5) // → 6
// TTL check
const remaining = await kv.ttl('session:abc123') // → seconds left
// Delete
await kv.del('session:abc123')kv = sdk.storage.kv
await kv.set("session:abc123", {"user_id": "usr-xxx"}, ttl=3600)
session = await kv.get("session:abc123")
alive = await kv.exists("session:abc123")
count = await kv.incr("counter:api-calls")
remaining = await kv.ttl("session:abc123")
await kv.delete("session:abc123")kv := client.Storage.Kv
err = kv.Set(ctx, "session:abc123", map[string]any{"userId": "usr-xxx"}, 3600)
val, err := kv.Get(ctx, "session:abc123")
alive, err := kv.Exists(ctx, "session:abc123")
count, err := kv.Incr(ctx, "counter:api-calls", 1)
remaining, err := kv.TTL(ctx, "session:abc123")
err = kv.Del(ctx, "session:abc123")var kv = client.Storage.Kv;
// Set with TTL (seconds)
await kv.SetAsync("session:abc123", new { userId = "usr-xxx", plan = "pro" }, ttl: 3600);
// Get
var session = await kv.GetAsync("session:abc123");
// → "{\"userId\":\"usr-xxx\",\"plan\":\"pro\"}" (always a string — parse if needed)
// Exists check
var alive = await kv.ExistsAsync("session:abc123"); // → true
// Atomic counter
await kv.SetAsync("counter:api-calls", "0");
var count = await kv.IncrAsync("counter:api-calls"); // → 1
var count2 = await kv.IncrAsync("counter:api-calls", 5); // → 6
// TTL check
var remaining = await kv.TtlAsync("session:abc123"); // → seconds left
// Delete
await kv.DelAsync("session:abc123");Path Isolation Contract#
cpod-backend resolves the actual storage path from the JWT — never from the client. The SDK consumer always writes short relative paths like config/settings.json. The backend maps this to:
| Tier | Actual MinIO / MongoDB path |
|---|---|
private | /{tenantId}/apps/{appId}/{path} |
user | /{tenantId}/users/{userId}/apps/{appId}/{path} |
shared | /{tenantId}/shared/{path} |
Two different apps writing config/settings.json at tier private will never collide — the appId segment keeps them separated. An app can only read its own private data; it cannot request another app's appId.
Register Your App#
Register your service to get an app_id, then exchange credentials for a Bearer token:
curl -X POST https://api.cyberpod.app/api/v1/apps/register \
-H "Authorization: Bearer $CPOD_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "my-service", "description": "My service"}'
# → { "app_id": "app_a1b2c3d4" }See Apps & MCP for the full registration and credential flow.
EDM Metadata Entities#
The primitives above (files, db, kv) handle raw storage operations. Two EDM entities represent the metadata layer on top — queryable through the standard EDM API.
StorageObject#
A StorageObject is the EDM metadata record for every file written via sdk.storage.files. The bytes live in MinIO; the metadata document lives in MongoDB and is queryable by tier, path, content type, size, and checksum.
ID prefix: sobj-
| Field | Type | Required | Description |
|---|---|---|---|
id | string | auto | sobj-{uuid} |
tenantId | string | auto | Stamped from JWT |
appId | string | auto | Stamped from OAuth client_id |
userId | string | no | Set only for tier=user |
tier | enum | yes | private | user | shared |
path | string | yes | Relative path supplied by the SDK consumer |
minioKey | string | auto | Full MinIO object key — resolved server-side, never trusted from client |
size | integer | yes | Object size in bytes |
contentType | string | yes | MIME content type |
checksum | string | no | SHA-256 hex digest of the object bytes |
createdAt | string | auto | ISO 8601 UTC |
updatedAt | string | auto | ISO 8601 UTC |
deletedAt | string|null | auto | ISO 8601 UTC when soft-deleted |
import { CpodClient } from '@cpod/sdk'
const sdk = CpodClient.fromEnv()
// List StorageObject records for private-tier files
const result = await sdk.storage.objects.list({ tier: 'private', pageSize: 20 })
for (const obj of result.data) {
console.log(obj.id, obj.path, obj.size, obj.contentType)
}
// Get a single StorageObject by ID
const obj = await sdk.storage.objects.get('sobj-abc123')
// Create a StorageObject metadata record
const created = await sdk.storage.objects.create({
tier: 'private',
path: 'reports/q1.pdf',
size: 204800,
contentType: 'application/pdf',
checksum: 'e3b0c44298fc1c149afb...',
})
// Soft-delete
await sdk.storage.objects.delete('sobj-abc123')from cpod import CpodClient
from cpod.storage import ListStorageObjectsOptions
sdk = CpodClient.from_env()
result = await sdk.storage.objects.list(
ListStorageObjectsOptions(tier="private", page_size=20)
)
for obj in result.data:
print(obj.id, obj.path, obj.size, obj.content_type)
obj = await sdk.storage.objects.get("sobj-abc123")client := cpod.New(os.Getenv("CPOD_API_KEY"))
result, err := client.Storage.Objects.List(ctx, &storage.ListStorageObjectsOptions{
Tier: storage.TierPrivate,
PageSize: 20,
})
for _, obj := range result.Data {
fmt.Println(obj.ID, obj.Path, obj.Size, obj.ContentType)
}
obj, err := client.Storage.Objects.Get(ctx, "sobj-abc123")var client = CpodClient.FromEnv();
// List StorageObject records for private-tier files
var result = await client.Storage.Objects.ListAsync(
new ListStorageObjectsOptions { Tier = StorageTier.Private, PageSize = 20 });
foreach (var obj in result.Data)
{
Console.WriteLine($"{obj.Id} {obj.Path} {obj.Size} {obj.ContentType}");
}
// Get a single StorageObject by ID
var single = await client.Storage.Objects.GetAsync("sobj-abc123");
// Create a StorageObject metadata record
var created = await client.Storage.Objects.CreateAsync(new CreateStorageObjectInput
{
Tier = StorageTier.Private,
Path = "reports/q1.pdf",
Size = 204800,
ContentType = "application/pdf",
Checksum = "e3b0c44298fc1c149afb...",
});
// Soft-delete
await client.Storage.Objects.DeleteAsync("sobj-abc123");StorageRecord#
A StorageRecord is the EDM metadata record for every document written via sdk.storage.db. The underlying data is stored in MongoDB, namespaced by tenantId + appId + collection. StorageRecord adds EDM-level queryability: filter by tier, collection, or full-text search across value.
ID prefix: srec-
| Field | Type | Required | Description |
|---|---|---|---|
id | string | auto | srec-{uuid} |
tenantId | string | auto | Stamped from JWT |
appId | string | auto | Stamped from OAuth client_id |
userId | string | no | Set only for tier=user |
tier | enum | yes | private | user | shared |
collection | string | yes | Collection name as supplied by the SDK consumer |
key | string | yes | Document key within the collection |
value | object | yes | Arbitrary JSON document |
ttl | integer|null | no | TTL in seconds — document auto-expires via MongoDB TTL index |
version | integer | auto | Optimistic concurrency counter, incremented on every update |
createdAt | string | auto | ISO 8601 UTC |
updatedAt | string | auto | ISO 8601 UTC |
deletedAt | string|null | auto | ISO 8601 UTC when soft-deleted |
// List StorageRecord records in a specific collection
const result = await sdk.storage.records.list({
tier: 'private',
collection: 'preferences',
pageSize: 50,
})
for (const record of result.data) {
console.log(record.id, record.key, record.version)
}
// Create a StorageRecord with a TTL
const created = await sdk.storage.records.create({
tier: 'user',
collection: 'sessions',
key: 'session:abc123',
value: { userId: 'usr-001', plan: 'pro' },
ttl: 3600,
})
// Get a single StorageRecord by ID
const record = await sdk.storage.records.get('srec-xyz789')
// Soft-delete
await sdk.storage.records.delete('srec-xyz789')from cpod.storage import ListStorageRecordsOptions, CreateStorageRecordInput
result = await sdk.storage.records.list(
ListStorageRecordsOptions(tier="private", collection="preferences", page_size=50)
)
for record in result.data:
print(record.id, record.key, record.version)
created = await sdk.storage.records.create(
CreateStorageRecordInput(
tier="user",
collection="sessions",
key="session:abc123",
value={"user_id": "usr-001", "plan": "pro"},
ttl=3600,
)
)result, err := client.Storage.Records.List(ctx, &storage.ListStorageRecordsOptions{
Tier: storage.TierPrivate,
Collection: "preferences",
PageSize: 50,
})
for _, rec := range result.Data {
fmt.Println(rec.ID, rec.Key, rec.Version)
}
ttl := 3600
created, err := client.Storage.Records.Create(ctx, storage.CreateStorageRecordInput{
Tier: storage.TierUser,
Collection: "sessions",
Key: "session:abc123",
Value: map[string]any{"userId": "usr-001", "plan": "pro"},
TTL: &ttl,
})// List StorageRecord records in a specific collection
var result = await client.Storage.Records.ListAsync(new ListStorageRecordsOptions
{
Tier = StorageTier.Private,
Collection = "preferences",
PageSize = 50,
});
foreach (var record in result.Data)
{
Console.WriteLine($"{record.Id} {record.Key} {record.Version}");
}
// Create a StorageRecord with a TTL
var created = await client.Storage.Records.CreateAsync(new CreateStorageRecordInput
{
Tier = StorageTier.User,
Collection = "sessions",
Key = "session:abc123",
Value = new { userId = "usr-001", plan = "pro" },
Ttl = 3600,
});
// Get a single StorageRecord by ID
var single = await client.Storage.Records.GetAsync("srec-xyz789");
// Soft-delete
await client.Storage.Records.DeleteAsync("srec-xyz789");