Token Permissions

Token permissions enable cross-app storage access. When App A needs to read or write data in App B's storage, App B uses token_permissions.json to grant access based on tokens App A has.

The Problem

By default, each app's storage is isolated. An app can only access its own storage paths (defined in storage.json).

But sometimes apps need to share data:

Token permissions solve this without compromising security.

How It Works

Permission checking happens in phases:

  1. Public paths - Anyone can read from /public/* (no permission needed)
  2. Same-app access - An app can always access its own storage
  3. Cross-app access - Check if the requesting app has tokens that grant access

For phase 3, the target app (the one being accessed) must define permissions in token_permissions.json.

Creating Token Permissions

Place token_permissions.json in your app's root directory:

{
  "@malv/interview": {
    "interview_access": [
      {
        "type": "storage",
        "access": "read",
        "prefix": "/teams/<token.teamId>/projects/<token.projectId>/config.json",
        "description": "Read project configuration for interviews"
      }
    ]
  }
}

This grants read access to @malv/interview when it has an interview_access token, but only to paths matching the prefix.

Structure

{
  "@requesting-app-id": {
    "token_type": [
      {
        "type": "storage",
        "access": "read" | "write" | "delete",
        "prefix": "/path/with/<token.field>/templates/",
        "description": "Why this access is needed"
      }
    ]
  }
}
Field Description
App ID key The app that will request access
Token type key Token type from that app's tokens.json
type Always "storage"
access "read", "write", or "delete"
prefix Path prefix (supports <token.field> templates)
description Human-readable explanation

Path Templates

Use <token.fieldName> to substitute values from the token's payload:

{
  "prefix": "/teams/<token.teamId>/projects/<token.projectId>/"
}

If the token has { "teamId": "team-123", "projectId": "proj-456" }, the prefix becomes:

/teams/team-123/projects/proj-456/

This ensures access is scoped to the specific team and project in the token.

Complete Example

Scenario

@malv/interview needs to read project configs from @malv/research storage.

Step 1: Define the token in @malv/interview

In @malv/interview/tokens.json:

{
  "interview_access": {
    "description": "Token for accessing interview configurations",
    "schema": {
      "type": "object",
      "properties": {
        "projectId": { "type": "string" },
        "teamId": { "type": "string" }
      },
      "required": ["projectId", "teamId"]
    }
  }
}

Step 2: Grant access in @malv/research

In @malv/research/token_permissions.json:

{
  "@malv/interview": {
    "interview_access": [
      {
        "type": "storage",
        "access": "read",
        "prefix": "/teams/<token.teamId>/projects/<token.projectId>/config.json",
        "description": "Read project config for interviews"
      },
      {
        "type": "storage",
        "access": "read",
        "prefix": "/teams/<token.teamId>/projects/<token.projectId>/versions/",
        "description": "Read published config versions"
      }
    ]
  }
}

Step 3: Use in the tool

In @malv/interview/src/tools/start_session.ts:

export default async function start_session(
  input: Input,
  capabilities: Capabilities
): Promise<Output> {
  // Access @malv/research storage
  // This works because we have interview_access token
  // and @malv/research grants permission in token_permissions.json
  const researchStorage = capabilities.storage.use('@malv/research');
  
  const config = await researchStorage.get(
    `/teams/${input.teamId}/projects/${input.projectId}/config.json`
  );
  
  return { config: JSON.parse(config.asString()) };
}

Multiple Token Types

Grant different access levels to different token types:

{
  "@malv/interview": {
    "interview_access": [
      {
        "type": "storage",
        "access": "read",
        "prefix": "/teams/<token.teamId>/projects/<token.projectId>/",
        "description": "Read-only access for running interviews"
      }
    ],
    "admin_access": [
      {
        "type": "storage",
        "access": "read",
        "prefix": "/teams/<token.teamId>/",
        "description": "Read all team data"
      },
      {
        "type": "storage",
        "access": "delete",
        "prefix": "/teams/<token.teamId>/",
        "description": "Delete team data for cleanup"
      }
    ]
  }
}

Multiple Apps

Grant access to multiple apps:

{
  "@malv/interview": {
    "interview_access": [
      { "type": "storage", "access": "read", "prefix": "/teams/<token.teamId>/" }
    ]
  },
  "@malv/analytics": {
    "analytics_access": [
      { "type": "storage", "access": "read", "prefix": "/teams/<token.teamId>/metrics/" }
    ]
  }
}

Permission Flow

┌─────────────────────────────────────────────────────────┐
│ @malv/interview calls:                                  │
│ storage.use("@malv/research").get("/teams/t1/...")      │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│ Storage Service checks:                                 │
│                                                         │
│ 1. Is this public storage? NO                           │
│ 2. Is @malv/interview accessing its own storage? NO     │
│ 3. Check @malv/research/token_permissions.json:         │
│    - Rules for @malv/interview? YES                     │
│    - Rules for interview_access token? YES              │
│    - Path matches prefix after substitution? YES        │
│                                                         │
│ Result: ACCESS GRANTED                                  │
└─────────────────────────────────────────────────────────┘

Debugging

If access is denied, check:

  1. Token exists - Does the tool have the required token in input_tokens?
  2. File exists - Does token_permissions.json exist in the target app?
  3. App ID matches - Is the app ID exactly right (e.g., "@malv/interview")?
  4. Token type matches - Does it match a token in the requesting app's tokens.json?
  5. Path matches - Does the requested path start with the prefix after template substitution?
  6. Access type matches - Is it "read" for GET, "write" for PUT, "delete" for DELETE?

Difference from storage.json

File Purpose
storage.json What storage paths your app can access
token_permissions.json What other apps can access in your storage

Think of it this way:

Best Practices

Use specific paths - Avoid broad prefixes like /. Be as specific as possible:

// Good - specific path
"prefix": "/teams/<token.teamId>/projects/<token.projectId>/config.json"

// Risky - too broad
"prefix": "/teams/<token.teamId>/"

Document with descriptions - Explain why access is needed:

{
  "description": "Read project config to display interview questions"
}

Match token lifecycles - Short-lived tokens should have narrow access. Long-lived admin tokens might have broader access.

Rebuild after changes - After editing token_permissions.json, rebuild and redeploy your app so the changes take effect.