Made Open

Credential Wallet & Capability Activation

The Inversion

Traditional platforms manage your credentials for you — you enter an API key into a settings form, the platform stores it in their database, and you hope they don't breach it.

The Credential Wallet inverts this: you control your credentials, and the platform's capabilities activate based on what you provide.

Add a Twilio API key → voice calling and SMS features appear in the dashboard. Add a Microsoft Graph OAuth token → contacts, calendar, email read/send, and mailbox settings sync activates. Remove a credential → those capabilities instantly deactivate.

What the Wallet Stores

Credential TypeExamples
API keysTwilio Account SID + Auth Token, OpenRouter API key
OAuth tokensMicrosoft Graph access + refresh tokens, Google OAuth
Passwords (encrypted)IMAP/SMTP credentials
Webhook secretsTwilio webhook signing secret
Device tokensPer-device authentication tokens (Android, Windows agent)

Credentials are never stored in the platform's backend database in plaintext. They are encrypted before storage and only decrypted in memory when needed by a plugin.

Supabase Vault

Credentials are stored in Supabase Vault — PostgreSQL's built-in secrets management using pgsodium encryption.

The hub does not call vault.create_secret directly — it invokes two Postgres RPC wrappers that the application layer uses as the only entry points to Vault:

// apps/hub/src/api/routes/credentials.ts (POST /api/credentials)
await supabase.rpc('vault_create_secret', { p_name: secretName, p_secret: fieldValue });

// DELETE /api/credentials/:type
await supabase.rpc('vault_delete_secret', { p_name: key });

Secret names follow the convention ${userId}:${credentialType}:${fieldKey} (e.g. u_abc:twilio:accountSid).

The frontend uses Supabase Auth to authenticate. When a plugin needs credentials, the hub resolves the secret values via Vault and injects them into the plugin's PluginContext.config object before the V8 isolate runs. Plugins never see raw credentials outside the sandbox. Note: at HEAD, PluginManager accepts config as an injected parameter; wiring the Vault → PluginManager lookup is the responsibility of whoever constructs the plugin invocation (see apps/hub/src/services/plugins/PluginManager.ts).

Credential Metadata Table

While secrets are stored in Supabase Vault (encrypted at rest via pgsodium), credential metadata lives in the user_credentials table. This table tracks which credentials a user has added, their active status, and references to the corresponding Vault entries.

user_credentials (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_id        uuid NOT NULL REFERENCES auth.users(id),
  credential_type text NOT NULL,       -- 'twilio' | 'microsoft365' | 'openrouter' | 'ollama'
  display_info    text,                -- non-secret hint (e.g., phone number, tenant ID)
  is_active       boolean DEFAULT true,
  vault_keys      text[] NOT NULL DEFAULT '{}', -- array of Supabase Vault secret names
  created_at      timestamptz DEFAULT now(),
  updated_at      timestamptz DEFAULT now(),
  UNIQUE (owner_id, credential_type)
)
-- RLS: users see only their own credentials

The vault_keys array contains the names passed to the vault_create_secret RPC. For a Twilio credential, this is ['${userId}:twilio:accountSid', '${userId}:twilio:authToken', '${userId}:twilio:phoneNumber']. The actual secret values are never stored in this table. The migration definition lives in supabase/migrations/00031_credential_wallet.sql.

REST API

The hub exposes three endpoints for credential management. All require a valid Bearer JWT in the Authorization header.

GET /api/credentials

Returns all credentials for the authenticated user. Returns metadata only — never secrets or vault key names.

Response: 200 OK

[
  {
    "credential_type": "twilio",
    "display_info": "+1 727 555 0100",
    "is_active": true,
    "created_at": "2026-01-15T10:30:00.000Z"
  }
]

POST /api/credentials

Stores credential secrets in Supabase Vault and upserts metadata in user_credentials.

Request:

{
  "type": "twilio",
  "fields": {
    "accountSid": "ACxxxxxxxxxxxxxxx",
    "authToken": "xxxxxxxxxxxxxxxxxx",
    "phoneNumber": "+1 727 555 0100"
  }
}

Each field is stored as a separate Vault secret. The display_info is derived from the credential type (e.g., phoneNumber for Twilio, tenantId for Microsoft 365).

Response: 201 Created

{
  "credential_type": "twilio",
  "display_info": "+1 727 555 0100",
  "is_active": true,
  "created_at": "2026-01-15T10:30:00.000Z"
}

DELETE /api/credentials/:type

Removes all Vault secrets for the credential and deletes the metadata row.

Response: 204 No Content

W3C Verifiable Credentials + DIDs

When federation ships, credentials migrate to a portable Credential Wallet using W3C Verifiable Credentials:

  • Client-side encrypted storage (IndexedDB + Web Crypto API for browser; hardware keychain for mobile)
  • Credentials follow the user — portable across any instance of the platform
  • The wallet is unlocked with the user's master password
  • Keys are derived from the user's Self-Sovereign Identity (DID) private key

Microsoft Graph OAuth Scopes

When a user authorizes Microsoft 365, the hub requests these scopes via the OAuth flow (GET /api/oauth/microsoft/authorize):

ScopePurpose
User.ReadUser profile (display name, email address)
Contacts.ReadRead Exchange contacts
Calendars.ReadRead calendar events
Mail.ReadRead email messages
Mail.ReadWriteMove, archive, and delete emails
Mail.SendSend email on behalf of the user
MailboxSettings.ReadWriteRead and update auto-reply, timezone, and signature settings
Files.ReadRead OneDrive files
Tasks.ReadRead To Do / Planner tasks
offline_accessObtain a refresh token for background sync

Backward compatibility: Tokens obtained before Mail.ReadWrite, Mail.Send, and MailboxSettings.ReadWrite were added (read-only tokens) remain valid. The hub gracefully degrades — read-only tokens still power contact sync, calendar sync, and email read. The email compose and mailbox settings features simply stay inactive until the user re-authorizes with the full scope set. No re-login is required for features that do not need the new scopes.

The Capability Registry

Capability reflection is implemented by apps/hub/src/api/routes/capabilities.ts (there is no standalone CapabilityRegistryService). The route maps credential_type values to the credential field keys they satisfy, then filters a provider manifest (TWILIO_MANIFEST from @made-open/shared) to the domains whose requiredCredentials are satisfied by the user's active fields.

// apps/hub/src/api/routes/capabilities.ts
const CREDENTIAL_TYPE_FIELDS: Record<string, string[]> = {
  twilio: ['accountSid', 'authToken', 'phoneNumber'],
  'twilio-api-key': ['apiKey', 'apiSecret', 'twimlAppSid'],
};

The Twilio manifest is the only provider currently wired through this reflection path at HEAD. Additional providers (Microsoft Graph, Google, OpenRouter) still activate via the presence of credentials in user_credentials, but are not yet represented in a shared *_MANIFEST and are not filtered by /api/capabilities. The illustrative mapping below shows the intended shape once those manifests land:

// Aspirational — not yet wired in code at this commit
const CAPABILITY_MAP = {
  twilio:       ['communication.voice', 'communication.sms', 'communication.video'],
  microsoft365: ['connector.contacts', 'connector.calendar', 'connector.email', 'connector.onedrive'],
  openrouter:   ['ai.chat', 'ai.rag'],
  google:       ['connector.gmail', 'connector.google_calendar', 'connector.google_contacts'],
};

The Activation Flow

1. User logs in (Supabase Auth)
2. Hub queries Vault for user's credentials
3. Capability Registry maps credentials → active capabilities
4. Active capabilities stored in user's session
5. Frontend fetches active capabilities
6. UI renders only features that are active

Example:
  Credentials present: [twilio.account_sid, ms_graph.oauth_token]
  Active capabilities: [voice, sms, video, contacts, calendar, email, onedrive]
  Hidden: [ai.chat] (no OpenRouter key), [gmail] (no Google token)

Adding a New Credential (UX Flow)

User opens Credential Wallet in web app
  └─► Selects "Add Credential" → picks "Microsoft 365"
        └─► OAuth redirect to Microsoft login
              └─► Microsoft returns access + refresh tokens
                    └─► Tokens stored in Supabase Vault
                          └─► Capability Registry activates: contacts, calendar, email
                                └─► Dashboard updates live: new sections appear
                                      └─► MS Graph connector begins first sync

API flow (programmatic):

POST /api/credentials
  Body: { "type": "microsoft365", "fields": { "accessToken": "...", "refreshToken": "...", "tenantId": "..." } }
  └─► Each field stored in Vault via vault.create_secret()
        └─► Metadata row upserted in user_credentials
              └─► Capability Registry activates: contacts, calendar, email
                    └─► Dashboard updates live: new sections appear

Credential Security in Plugins

The Plugin Manager never passes raw credentials to plugins. It injects only what the plugin declared in its manifest's configSchema:

// ms-graph plugin.json configSchema:
{
  "properties": {
    "accessToken": { "type": "string" },
    "tenantId": { "type": "string" }
  }
}

The Plugin Manager retrieves the ms_graph.oauth_token from Vault, extracts accessToken and tenantId, and injects only those into PluginContext.config. The plugin receives the minimum necessary credentials, inside the V8 sandbox, with no way to exfiltrate them except through declared network:outbound permissions.

Revoking Access

Revoking is immediate and complete:

  1. User removes credential from wallet (DELETE /api/credentials/:type — removes Vault secrets + metadata)
  2. Capability Registry deactivates associated capabilities
  3. Running plugin is stopped by Plugin Manager
  4. Frontend capabilities update in real-time (Supabase Realtime subscription)
  5. All future API calls for those capabilities are rejected by Policy Service