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
PrimitiveBacking storeBest forNot for
storage.filesMinIO (S3-compatible)Binary blobs, exports, user uploads, large filesFrequent small reads, structured queries
storage.dbMongoDBStructured JSON, filtered lists, indexed fieldsBinary data, very high-frequency ephemeral state
storage.kvRedisSession state, rate limit counters, feature flags, ephemeral locksDurable 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.EmitAuditEvent fires on every write — impossible to bypass with a direct DB connection.
  • tenantId stamped server-side. cpod-backend stamps tenantId from 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 .env files 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:

ScenarioUse tier
App config, internal state, processed resultsprivate
User preferences, per-user documents, user-uploaded filesuser
Org-wide reference data, shared templates, published exportsshared

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:

TierRead scopeWrite scope
privatestorage.private.readstorage.private.write
userstorage.user.readstorage.user.write
sharedstorage.shared.readstorage.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()