Connections (credentials vault)
defineConnection — a per-user, encrypted store for third-party credentials, with the OAuth handshake, refresh-before-use, a resolver for handlers and plugins, and a connect UI, from one declaration.
An app that acts on a third party on behalf of a user — Jira with that user's token, Slack with that user's OAuth grant — needs five things, none of them app-specific and all of them easy to get subtly wrong:
- a per-user token table,
- encryption, so the tokens are not sitting in the database in the clear,
- an OAuth authorize/callback pair with anti-CSRF state,
- an expiry check with a refresh, without stampeding on the refresh token,
- a resolver the request path can ask for "the credential for this user".
defineConnection is that whole stack from one declaration.
Declare a connection
One *.connection.ts file per connection, default-exported. The file is
server-only — it holds a client secret, and it is never part of the browser
bundle.
// api/connections/jira.connection.ts
import { defineConnection } from '@voltro/runtime'
import { serverEnv } from '../env/server'
export default defineConnection({
id: 'jira',
kind: 'oauth2',
label: 'Jira',
authorizeUrl: 'https://auth.atlassian.com/authorize',
tokenUrl: 'https://auth.atlassian.com/oauth/token',
clientId: serverEnv.JIRA_CLIENT_ID,
clientSecret: serverEnv.JIRA_CLIENT_SECRET,
scopes: ['read:jira-work', 'offline_access'],
authorizeParams: { audience: 'api.atlassian.com', prompt: 'consent' },
identify: async (tokens) => {
const res = await fetch('https://api.atlassian.com/me', {
headers: { authorization: `Bearer ${tokens.accessToken}` },
})
const me = await res.json() as { account_id: string, email: string }
return { accountId: me.account_id, accountLabel: me.email }
},
})For a provider with no OAuth app — or where a personal access token is simply
the supported path — use kind: 'pat':
// api/connections/github.connection.ts
import { defineConnection } from '@voltro/runtime'
export default defineConnection({
id: 'github',
kind: 'pat',
label: 'GitHub',
instructionsUrl: 'https://github.com/settings/tokens',
validate: async (token) => {
const res = await fetch('https://api.github.com/user', {
headers: { authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('GitHub rejected that token')
const me = await res.json() as { login: string }
return { accountLabel: me.login }
},
})validate is worth writing. Without it, a mistyped token is accepted happily
and fails hours later inside a background job; with it, the user finds out while
they still have the token on their clipboard.
Use the credential
ctx.connections.get(id) returns the calling subject's credential,
refreshed if it was near expiry. There is no parameter for "some other
subject" — the facade is bound to the request.
// api/actions/syncIssues.action.server.ts
export default async function syncIssues(input: { projectKey: string }, ctx) {
const jira = await ctx.connections.get('jira')
const res = await fetch(`https://api.atlassian.com/ex/jira/search?jql=project=${input.projectKey}`, {
headers: { authorization: `Bearer ${jira.accessToken}` },
})
return { count: (await res.json()).total }
}Use ctx.connections.tryGet(id) for the "use it if we have it" branch — it
returns null when nothing is connected. A failing refresh still throws:
silently degrading a connected-but-broken account to "not connected" would hide
a revoked grant behind a feature that quietly does nothing.
ctx.connections is absent when the app declares no *.connection.ts, so
reaching for it in an app with no connections is a type error rather than a
runtime surprise.
The connect UI
useConnection gives you one connection's live state plus its operations.
Everything reactive comes from a single subscription, so a completed OAuth
callback updates an open settings page with no polling.
import { useConnection } from '@voltro/client'
import { ConnectAccount } from '@voltro/ui'
export const IntegrationSettings = () => {
const jira = useConnection('app', 'jira')
const github = useConnection('app', 'github')
return (
<>
<ConnectAccount connection={jira} loading={jira.loading} />
<ConnectAccount connection={github} instructionsUrl="https://github.com/settings/tokens" />
</>
)
}useConnections('app') returns the whole list if you would rather render the
page yourself:
const { connections, get, loading } = useConnections('app')
// each entry: { connectionId, kind, label, status, accountLabel, scopes, expiresAt, lastError }connect() opens the provider's consent screen in a popup by default (which
keeps the current page — and any unsaved form state — mounted) and falls back to
a full navigation when the popup is blocked. Pass { mode: 'redirect' } to
navigate the tab instead.
The token is never on the client. The wire shape carries status, account and expiry — nothing else. If you find yourself wanting the token in the browser, the call that needs it belongs on the server.
Status
| Status | Meaning |
|---|---|
disconnected |
No credential on file for this user. |
connected |
A usable credential is on file. |
expired |
The access token is past its deadline and there is no refresh token. Re-consent needed. |
revoked |
The provider refused the refresh. The stored tokens were cleared; only reconnecting restores it. |
error |
The last refresh failed transiently (5xx / network). Tokens retained; the next use retries. |
expired/revoked/error are deliberately not "connected" — that distinction
is what makes the UI prompt rather than silently do nothing.
Encryption is not optional
Tokens are written through the framework's field cipher — the same
FieldCipher .encrypted() columns use, not a second mechanism with its own
key management. An app that declares a connection and has no cipher
configured refuses to boot, with a message naming the connections. There is no
plaintext fallback and no warn-and-continue path.
// app.config.ts
import { governancePlugin } from '@voltro/plugin-governance'
export default defineApiConfig({
plugins: [
governancePlugin({ fieldEncryption: { secretKey: 'VOLTRO_FIELD_ENCRYPTION_KEY' } }),
],
})Refresh
Resolving checks the deadline with 60s of skew and renews ahead of it, so no call site needs a retry-on-401 dance. Two layers keep concurrent refreshes from stampeding:
- an in-process single-flight, so many handlers on one replica share one refresh;
- a compare-and-set lease on the row, so two replicas do not both spend the refresh token — which, with a provider that rotates refresh tokens, would invalidate the grant outright.
A provider that does not rotate simply omits refresh_token from its reply; the
existing one is kept rather than overwritten with null.
Plugins
A plugin never sees a request context, so the vault also publishes a
process-level resolver. @voltro/plugin-atlassian consumes it directly:
// app.config.ts
import { atlassianPlugin } from '@voltro/plugin-atlassian'
import { connectionCredentials } from '@voltro/plugin-atlassian/connection'
plugins: [
atlassianPlugin({
credentialsResolver: connectionCredentials({
connectionId: 'jira',
baseUrl: serverEnv.JIRA_BASE_URL,
}),
}),
]The plugin's contract is unchanged — it still receives a
(subject) => Effect<AtlassianCredentials> and still never reads your schema.
What changes is who owns the token. A plugin with its own credential resolver
keeps working exactly as before; this is an additional way to satisfy the same
option, not a replacement.
What the framework stores
Two tables, created only when the app declares a connection:
_voltro_connections— one row per (connection, subject). Access and refresh tokens are ciphertext. Reactive, which is what makes the connect UI live._voltro_connection_grants— an in-flight OAuth handshake (single-use state, encrypted PKCE verifier, 10-minute TTL).
The callback endpoint is GET /_voltro/connections/<id>/callback, mounted
automatically under both voltro dev and voltro serve. Register it as the
redirect URI on your OAuth app, or set redirectUri on the declaration. The
app's public origin comes from VOLTRO_PUBLIC_URL.
Deliberate non-goals
- Disconnect does not revoke at the provider. Providers disagree on whether
a revocation endpoint exists, what it takes, and whether it kills sibling
sessions.
disconnect()forgets our copy and says so, rather than pretending to a revocation it cannot guarantee. Revoke in the provider's own UI when that matters. - No app-wide connection. A credential belongs to a subject. An anonymous caller is refused rather than bucketed under a shared pseudo-subject, which would be a credential every visitor shares. For a service account, store it as a secret, not a connection.
redirectTois a same-origin path only. An absolute URL is rejected — otherwise every app declaring a connection would ship an open redirector.