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:
- Declare what paths your app can access in
storage.json - Use the storage capability in your tools
- 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:
- Users can only access their own data
- Teams can only access their team's data
- No path traversal attacks are possible
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:
- Every request is verified against
storage.json - Token signatures are checked before allowing access
- Path templates prevent users from accessing other users' data
- Cross-app access requires explicit declaration from both apps
You don't need to write permission checks in your tool code. If the request makes it to your tool, it's already authorized.