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
sdk.storage.files → MinIO binary blobs, large files, exports
sdk.storage.db → MongoDB structured JSON docs, queryable
sdk.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 keyThis 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 sdk.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 sdk.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 sdk.storage.files.getUrl({
tier: 'private',
path: 'exports/2026-q1-report.pdf',
expiresIn: 3600, // seconds
})
// Document DB — store structured data
await sdk.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 sdk.storage.db.list({
tier: 'shared',
prefix: 'templates/',
filter: { category: 'onboarding' },
})
// Key-Value — atomic counter with TTL
await sdk.storage.kv.set({
tier: 'private',
key: 'rate-limit:user:per-abc123',
value: '1',
ttl: 60, // seconds — auto-expires
})
const count = await sdk.storage.kv.increment({
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 sdk.storage.files.upload({
tier: 'private',
path: 'attachments/logo.png',
content: fs.readFileSync('logo.png'), // Buffer — SDK encodes to base64
contentType: 'image/png',
})For files larger than 100 MB, use multipart upload (chunked):
const upload = await sdk.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()