Docs

Docs

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#

code
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.

code
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.

code
┌────────────────────────────────────────────────────────────────────┐
│  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#

typescript
// 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.

typescript
// 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):

typescript
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()