Storage Design
cPod exposes three storage primitives through the SDK. Each maps to a different backing technology and is optimized for a different access pattern. All three share the same tenancy model, OAuth scope enforcement, and three-tier namespace structure.
Three Storage Primitives#
client.storage.files → MinIO binary blobs, large files, exports
client.storage.db → MongoDB structured JSON docs, queryable
client.storage.kv → Redis ephemeral, TTL-based, atomic ops
| Primitive | Backing store | Best for | Not for |
|---|---|---|---|
storage.files | MinIO (S3-compatible) | Binary blobs, exports, user uploads, large files | Frequent small reads, structured queries |
storage.db | MongoDB | Structured JSON, filtered lists, indexed fields | Binary data, very high-frequency ephemeral state |
storage.kv | Redis | Session state, rate limit counters, feature flags, ephemeral locks | Durable data, large values |
Why No Direct Database Access#
The SDK never holds MongoDB connection strings, Redis passwords, or MinIO access keys. All storage operations go through cpod-backend via REST.
What you have: What you DON'T have:
CPOD_CLIENT_ID mongodb://user:pass@host/db
CPOD_CLIENT_SECRET redis://default:pass@host:6379
minio access key + secret key
This design provides:
- Credential rotation without SDK changes. The platform rotates database credentials independently. Your app never redeployment.
- Audit trail on every storage operation.
AuditService.EmitAuditEventfires on every write — impossible to bypass with a direct DB connection. - tenantId stamped server-side.
cpod-backendstampstenantIdfrom the JWT before writing to any store. A client cannot forge their namespace. - Policy enforcement. Rego rules evaluated by the CoreSDK sidecar apply uniformly — even to storage operations. You cannot grant yourself storage scopes you didn't declare at app registration.
- No credential sprawl. One set of credentials (client ID + secret) per app. No per-developer database passwords, no credentials in
.envfiles committed to git.
Three-Tier Namespace#
Every storage operation targets one of three tiers. The tier determines who can see the data.
┌────────────────────────────────────────────────────────────────────┐
│ Tier │ Scope │ Key pattern │
│───────────│───────────────────────────│────────────────────────────│
│ private │ This app only │ {tenantId}/apps/ │
│ │ │ {appId}/{path} │
│───────────│───────────────────────────│────────────────────────────│
│ user │ Authenticated user, │ {tenantId}/users/ │
│ │ visible to all apps │ {userId}/apps/ │
│ │ they use │ {appId}/{path} │
│───────────│───────────────────────────│────────────────────────────│
│ shared │ All apps in tenant │ {tenantId}/shared/ │
│ │ │ {path} │
└────────────────────────────────────────────────────────────────────┘
The tier you use depends on who should see the data:
| Scenario | Use tier |
|---|---|
| App config, internal state, processed results | private |
| User preferences, per-user documents, user-uploaded files | user |
| Org-wide reference data, shared templates, published exports | shared |
The key pattern is constructed server-side from JWT claims. You never construct {tenantId}/apps/{appId}/... yourself — you call client.storage.db.set({ tier: 'private', path: 'config/settings.json', value: {...} }) and the platform handles namespacing.
OAuth Scopes Map to Tiers#
The three tiers map directly to OAuth scopes:
| Tier | Read scope | Write scope |
|---|---|---|
private | storage.private.read | storage.private.write |
user | storage.user.read | storage.user.write |
shared | storage.shared.read | storage.shared.write |
Declare only the scopes your app needs at registration. Tokens cannot grant scopes not declared at registration — even if you request them.
Storage Examples#
// Files — store a binary export
await client.storage.files.upload({
tier: 'private',
path: 'exports/2026-q1-report.pdf',
content: pdfBuffer, // Buffer or base64 string
contentType: 'application/pdf',
})
// Files — get a pre-signed download URL
const url = await client.storage.files.getUrl({
tier: 'private',
path: 'exports/2026-q1-report.pdf',
expiresIn: 3600, // seconds
})
// Document DB — store structured data
await client.storage.db.set({
tier: 'user',
path: 'preferences/dashboard-layout.json',
value: { columns: ['name', 'email', 'role'], pageSize: 25 },
})
// Document DB — query with filters
const docs = await client.storage.db.list({
tier: 'shared',
prefix: 'templates/',
filter: { category: 'onboarding' },
})
// Key-Value — atomic counter with TTL
await client.storage.kv.set({
tier: 'private',
key: 'rate-limit:user:per-abc123',
value: '1',
ttl: 60, // seconds — auto-expires
})
const count = await client.storage.kv.increment({
tier: 'private',
key: 'rate-limit:user:per-abc123',
})# Files — store a binary export
await client.storage.files.upload(
tier="private",
path="exports/2026-q1-report.pdf",
content=pdf_bytes, # bytes or base64 string
content_type="application/pdf",
)
# Files — get a pre-signed download URL
url = await client.storage.files.get_url(
tier="private",
path="exports/2026-q1-report.pdf",
expires_in=3600, # seconds
)
# Document DB — store structured data
await client.storage.db.set(
tier="user",
path="preferences/dashboard-layout.json",
value={"columns": ["name", "email", "role"], "pageSize": 25},
)
# Document DB — query with filters
docs = await client.storage.db.list(
tier="shared",
prefix="templates/",
filter={"category": "onboarding"},
)
# Key-Value — atomic counter with TTL
await client.storage.kv.set(
tier="private",
key="rate-limit:user:per-abc123",
value="1",
ttl=60, # seconds — auto-expires
)
count = await client.storage.kv.increment(
tier="private",
key="rate-limit:user:per-abc123",
)// Files — store a binary export
client.Storage.Files.Upload(ctx, &files.UploadInput{
Tier: "private",
Path: "exports/2026-q1-report.pdf",
Content: pdfBytes, // []byte or base64 string
ContentType: "application/pdf",
})
// Files — get a pre-signed download URL
url, _ := client.Storage.Files.GetURL(ctx, &files.GetURLInput{
Tier: "private",
Path: "exports/2026-q1-report.pdf",
ExpiresIn: 3600, // seconds
})
// Document DB — store structured data
client.Storage.DB.Set(ctx, &db.SetInput{
Tier: "user",
Path: "preferences/dashboard-layout.json",
Value: map[string]any{"columns": []string{"name", "email", "role"}, "pageSize": 25},
})
// Document DB — query with filters
docs, _ := client.Storage.DB.List(ctx, &db.ListInput{
Tier: "shared",
Prefix: "templates/",
Filter: map[string]any{"category": "onboarding"},
})
// Key-Value — atomic counter with TTL
client.Storage.KV.Set(ctx, &kv.SetInput{
Tier: "private",
Key: "rate-limit:user:per-abc123",
Value: "1",
TTL: 60, // seconds — auto-expires
})
count, _ := client.Storage.KV.Increment(ctx, &kv.IncrementInput{
Tier: "private",
Key: "rate-limit:user:per-abc123",
})// Files — store a binary export
await client.Storage.Files.UploadAsync(new UploadFileInput {
Tier = "private",
Path = "exports/2026-q1-report.pdf",
Content = pdfBytes, // byte[] or base64 string
ContentType = "application/pdf",
});
// Files — get a pre-signed download URL
var url = await client.Storage.Files.GetUrlAsync(new GetUrlInput {
Tier = "private",
Path = "exports/2026-q1-report.pdf",
ExpiresIn = 3600, // seconds
});
// Document DB — store structured data
await client.Storage.Db.SetAsync(new DbSetInput {
Tier = "user",
Path = "preferences/dashboard-layout.json",
Value = new { columns = new[] { "name", "email", "role" }, pageSize = 25 },
});
// Document DB — query with filters
var docs = await client.Storage.Db.ListAsync(new DbListInput {
Tier = "shared",
Prefix = "templates/",
Filter = new { category = "onboarding" },
});
// Key-Value — atomic counter with TTL
await client.Storage.Kv.SetAsync(new KvSetInput {
Tier = "private",
Key = "rate-limit:user:per-abc123",
Value = "1",
Ttl = 60, // seconds — auto-expires
});
var count = await client.Storage.Kv.IncrementAsync(new KvIncrementInput {
Tier = "private",
Key = "rate-limit:user:per-abc123",
});File Encoding#
Files are sent as base64-encoded strings in JSON bodies rather than multipart form data. See DD-006 for the rationale.
// The SDK handles base64 encoding automatically
await client.storage.files.upload({
tier: 'private',
path: 'attachments/logo.png',
content: fs.readFileSync('logo.png'), // Buffer — SDK encodes to base64
contentType: 'image/png',
})# The SDK handles base64 encoding automatically
await client.storage.files.upload(
tier="private",
path="attachments/logo.png",
content=open("logo.png", "rb").read(), # bytes — SDK encodes to base64
content_type="image/png",
)// The SDK handles base64 encoding automatically
content, _ := os.ReadFile("logo.png") // []byte — SDK encodes to base64
client.Storage.Files.Upload(ctx, &files.UploadInput{
Tier: "private",
Path: "attachments/logo.png",
Content: content,
ContentType: "image/png",
})// The SDK handles base64 encoding automatically
await client.Storage.Files.UploadAsync(new UploadFileInput {
Tier = "private",
Path = "attachments/logo.png",
Content = File.ReadAllBytes("logo.png"), // byte[] — SDK encodes to base64
ContentType = "image/png",
});For files larger than 100 MB, use multipart upload (chunked):
const upload = await client.storage.files.createMultipartUpload({
tier: 'shared',
path: 'exports/large-dataset.csv',
contentType: 'text/csv',
})
for (const chunk of chunks) {
await upload.uploadPart({ data: chunk })
}
await upload.complete()upload = await client.storage.files.create_multipart_upload(
tier="shared",
path="exports/large-dataset.csv",
content_type="text/csv",
)
for chunk in chunks:
await upload.upload_part(data=chunk)
await upload.complete()upload, _ := client.Storage.Files.CreateMultipartUpload(ctx, &files.CreateMultipartUploadInput{
Tier: "shared",
Path: "exports/large-dataset.csv",
ContentType: "text/csv",
})
for _, chunk := range chunks {
upload.UploadPart(ctx, &files.UploadPartInput{Data: chunk})
}
upload.Complete(ctx)var upload = await client.Storage.Files.CreateMultipartUploadAsync(new CreateMultipartUploadInput {
Tier = "shared",
Path = "exports/large-dataset.csv",
ContentType = "text/csv",
});
foreach (var chunk in chunks)
{
await upload.UploadPartAsync(new UploadPartInput { Data = chunk });
}
await upload.CompleteAsync();