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:

  1. a per-user token table,
  2. encryption, so the tokens are not sitting in the database in the clear,
  3. an OAuth authorize/callback pair with anti-CSRF state,
  4. an expiry check with a refresh, without stampeding on the refresh token,
  5. 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.
  • redirectTo is a same-origin path only. An absolute URL is rejected — otherwise every app declaring a connection would ship an open redirector.