Storage

Storage is where your data lives. It's permission-enforced, meaning every read and write is checked against declared permissions.

How Storage Works

You don't connect to a database directly. Instead, you:

  1. Declare what paths your app can access in storage.json
  2. Use the storage capability in your tools
  3. The storage service verifies permissions on every request

This keeps data secure by default. Apps can only touch paths they've explicitly declared.

Defining Permissions

Storage permissions are defined in storage.json:

{
  "same_app": {
    "/notes/<token.accountId>/": {
      "operations": ["read", "write", "list", "delete"],
      "tokenType": "account",
      "tokenFromApp": "@my-org/auth"
    }
  }
}

This says: "This app can read, write, list, and delete files under /notes/{accountId}/ for any user that has an account token."

Path Templates

Paths can include token fields using <token.field> syntax:

"/users/<token.accountId>/settings.json"
"/teams/<token.teamId>/projects/"

When a request comes in, these placeholders are replaced with actual values from the user's token. This ensures:

Operations

Operation What It Does
read Read a file
write Create or update a file
delete Remove a file
list List files under a prefix

Declare only what you need. If a tool only reads data, only include read.

Using Storage in Tools

Tools with the storage capability can access storage:

export default async function save_note(
  input: Input,
  capabilities: Capabilities
): Promise<Output> {
  const storage = capabilities.storage.use('@my-org/notes');
  
  // Write a file
  await storage.put('/notes/abc123/note1.json', {
    title: input.title,
    content: input.content,
    updated: Date.now()
  });
  
  // Read a file
  const note = await storage.get('/notes/abc123/note1.json');
  
  // List files
  const files = await storage.list('/notes/abc123/');
  
  // Delete a file
  await storage.delete('/notes/abc123/note1.json');
  
  return { success: true };
}

The use method scopes storage to a specific app. Paths are relative to that app's namespace.

Same-App vs Cross-App

Same-App Storage

Most of the time, apps access their own storage:

{
  "same_app": {
    "/private/config.json": {
      "operations": ["read", "write"]
    },
    "/users/<token.accountId>/": {
      "operations": ["read", "write", "list"],
      "tokenType": "account",
      "tokenFromApp": "@my-org/auth"
    }
  }
}

Cross-App Storage

Sometimes apps need to access another app's storage. This requires explicit permission from both sides:

{
  "cross_app": {
    "@my-org/files": {
      "/shared/<token.teamId>/": {
        "operations": ["read"],
        "tokenType": "team",
        "tokenFromApp": "@my-org/auth"
      }
    }
  }
}

This lets your app read from @my-org/files storage, but only paths under /shared/{teamId}/.

Storage Structure

Data is organized by app:

/apps/
├── @my-org/notes/
│   ├── private/
│   │   └── config.json
│   └── users/
│       ├── user1/
│       │   └── notes/
│       └── user2/
│           └── notes/
├── @my-org/files/
│   └── shared/
│       └── team1/
└── @my-org/auth/
    └── accounts/

Each app has its own namespace. This prevents accidental collisions and makes permissions clear.

Common Patterns

User-Specific Data

Store data per user using their account ID:

"/users/<token.accountId>/preferences.json"

Team-Shared Data

Store data that teams can share:

"/teams/<token.teamId>/projects/"

App Configuration

Store app-wide settings without tokens:

"/private/config.json": {
  "operations": ["read", "write"]
}

No tokenType means no token is required - this is for app-internal data.

Local Development

During development, storage is file-based. Data lives in your .malv/ directory. In production, it uses Cloudflare R2.

The API is identical in both environments - your code doesn't need to change.

Security Notes

Storage permissions are enforced at the infrastructure level:

You don't need to write permission checks in your tool code. If the request makes it to your tool, it's already authorized.