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.

Both voltro dev and voltro serve discover these declarations (also with .tsx). A successful load logs discovered connections with IDs and kinds, not credentials. The presence of the file also includes _voltro_connections and _voltro_connection_grants in the framework schema, matching voltro db plan. Dev auto-migration applies that schema when enabled; serve never applies DDL, so run the usual pre-deploy voltro db apply before starting it.

// 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 }
  },
})

clientId and clientSecret are thunks, not values. A *.connection.ts is imported by discovery before the env gate has run (and voltro doctor runs none), and serverEnv.X read at module top level refuses there by design. A thunk is read when the connection is used — an authorize URL, a token exchange — which is always after the gate. A plain string is still accepted for a value that is not an env read. A connection file that throws on import is a boot refusal, not a warning: an app that came up without its OAuth connection would 404 on the first click of "Connect".

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.

A token your own login obtained — storeCredential

Some integrations never go through the connect UI: the app's own login is the third-party credential (a PAT the user pastes once, a token an SSO flow hands back). The vault's write seam is a server function, storeCredential, and it is the same row the connect flow writes — encrypted with the same key, refreshed by the same code, read back by ctx.connections.get(id) and by connectionCredentials in a plugin.

// api/actions/loginWithPat.action.server.ts
import { storeCredential } from '@voltro/runtime'
import { jira } from '../connections/jira.connection'

export default async function loginWithPat(input: { pat: string }, ctx) {
  const subject = ctx.request.subject
  await storeCredential({
    store: ctx.store,
    definition: jira,
    subjectId: subject.id,
    tenantId: subject.tenantId ?? null,
    tokens: { accessToken: input.pat, scopes: [] },
    account: { accountLabel: input.pat.slice(0, 4) + '…' },
  })
  return { connected: true }
}

storeCredential upserts on (connectionId, subjectId): connecting again replaces the row rather than accumulating stale ones, so "which token is current" is never a heuristic. tokens.expiresAt (epoch ms) and refreshToken are optional; a credential without them is treated as long-lived. definition is the defineConnection value, so the row carries the connection's id and the vault's encryption applies exactly as it does for an OAuth grant — a PAT stored this way is never in your own table in plaintext, and never in subject.metadata.

_voltro_connections is a framework table like every other: it is planned, classified and applied by the same declarative differ as your own tables, on every dialect, on voltro db apply and on a voltro dev boot.

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.