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 Type | Examples |
|---|---|
| API keys | Twilio Account SID + Auth Token, OpenRouter API key |
| OAuth tokens | Microsoft Graph access + refresh tokens, Google OAuth |
| Passwords (encrypted) | IMAP/SMTP credentials |
| Webhook secrets | Twilio webhook signing secret |
| Device tokens | Per-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):
| Scope | Purpose |
|---|---|
User.Read | User profile (display name, email address) |
Contacts.Read | Read Exchange contacts |
Calendars.Read | Read calendar events |
Mail.Read | Read email messages |
Mail.ReadWrite | Move, archive, and delete emails |
Mail.Send | Send email on behalf of the user |
MailboxSettings.ReadWrite | Read and update auto-reply, timezone, and signature settings |
Files.Read | Read OneDrive files |
Tasks.Read | Read To Do / Planner tasks |
offline_access | Obtain 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:
- User removes credential from wallet (
DELETE /api/credentials/:type— removes Vault secrets + metadata) - Capability Registry deactivates associated capabilities
- Running plugin is stopped by Plugin Manager
- Frontend capabilities update in real-time (Supabase Realtime subscription)
- All future API calls for those capabilities are rejected by Policy Service