Provider seams

Operator·6 minutes to read

A provider seam is a stable interface with an interchangeable driver behind it: swap the driver and nothing above the seam — the run loop, the web app, the CLI — notices. @botseon/providers carries six of them. Every interface below is quoted directly from its source file.

auth

export interface AuthProvider {
  startSignIn(email: string): Promise<{ delivery: 'console' | 'email'; token?: string }>;
  completeSignIn(token: string): Promise<{ sessionId: string; userId: string; email: string }>;
  /**
   * D138 (M6 Task 3): the return widened additively with the session's active organisation.
   *
   * It is a **pointer, not a grant**. `resolveActiveOrganisation` (`@botseon/db`) answers with it
   * only where a live membership exists, so a forged or stale value buys exactly what a membership
   * grants. `null` is the ordinary state — the member is in their personal organisation — and an
   * expired session still returns `null` from this method whatever its pointer says.
   */
  verifySession(
    sessionId: string,
  ): Promise<{ userId: string; email: string; activeOrgId: string | null } | null>;
  /** D138: point this session at an organisation, or clear the pointer. */
  setActiveOrganisation(sessionId: string, orgId: string | null): Promise<void>;
  signOut(sessionId: string): Promise<void>;

Plus two optional members (D142), which only a driver for a deployment one machine reaches may implement:

  ownerAccount?(): Promise<OwnerAccount>;
  signInOwner?(email: string): Promise<{ sessionId: string; userId: string; email: string }>;

Ships: createLocalAuth (packages/providers/src/auth.local.ts) — magic tokens delivered out of band (botseon login-link, or the app container's log, in the home edition; e-mail later), sessions kept in auth_sessions, and the user and their personal organisation provisioned on first successful sign in. It implements the two optional members as well: ownerAccount answers whether this deployment has a single account, and signInOwner mints a session for it with no token in between — the login page's one-click Continue as owner on a single-user home deployment published on loopback.

A replacement must guarantee: a token from startSignIn completes exactly once (completeSignIn is where a session is actually minted), verifySession returns null rather than throwing for an unknown or expired session id, and signOut actually ends the session it names — nothing above this seam re-checks any of that itself. A driver for a deployment reachable from anywhere must implement neither optional member: their absence is what makes the one-click path structurally unreachable off the home edition, over and above the guard in apps/web/lib/local-signin.ts.

events

export interface EventBus {
  publish(topic: string, event: unknown): Promise<void>;
  subscribe(topic: string, handler: (event: unknown) => void): Promise<() => Promise<void>>;
}

Ships: createPgEventBus (Postgres LISTEN/NOTIFY on a single channel, topics multiplexed inside the JSON payload — what the compose stack's app and worker actually run) and createPgliteEventBus (an in-process equivalent for tests and single-process use), both in packages/providers/src/events.pg.ts.

A replacement must guarantee: every subscriber on a topic sees a published event (this is how a run's state reaches the web app without polling), and — load-bearing, not incidental — a published payload never carries tenant data unscoped over the wire. createPgEventBus publishes ids only, under Postgres's 8000-byte NOTIFY limit, and leaves a subscriber to read the actual row back under its own scope; a replacement that instead put a row's contents in the payload would leak across whatever scoping the transport itself does not enforce.

blobs

export interface BlobStore {
  put(key: string, data: Uint8Array, contentType: string): Promise<void>;
  get(key: string): Promise<Uint8Array | null>;
  delete(key: string): Promise<void>;
  signedUrl(key: string, ttlSeconds: number): Promise<string>;
}

Ships: createLocalBlobStore (packages/providers/src/blobs.local.ts) — local-disk storage under BOTSEON_BLOB_DIR, keyed by paths that always start with the organisation id so a bad key can never be written or read outside its own tenant's subtree, with signed download URLs built from this process's own HMAC (signBlobKey/verifyBlobKey) rather than a cloud provider's.

A replacement must guarantee: a key namespaced under the wrong organisation is refused, not merely inconvenienced — the local driver throws before it will put, get or mint a signedUrl for a key that fails its own org-prefix check — and a signedUrl actually expires: verifyBlobKey checks the signature and that the deadline is still in the future, in that order, and returns a plain boolean rather than a reason, so a caller can never distinguish "wrong signature" from "expired" and leak which one it was.

keys

packages/providers/src/keys.ts re-exports the type rather than declaring it — KeyStore lives in @botseon/core because the cipher that uses it (packages/db/src/crypto.ts's createCipher) does too, and neither needed a dependency on @botseon/providers just for a type.

export interface KeyStore {
  wrap(orgId: string, dek: Uint8Array): Promise<Uint8Array>;
  unwrap(orgId: string, wrapped: Uint8Array): Promise<Uint8Array>;
  destroyKek(orgId: string): Promise<void>; // crypto-shred: later unwraps fail
}

Ships: createLocalKeyStore (packages/providers/src/keys.local.ts) — an organisation's key-encryption key is generated on first use and wrapped under a single master key from the environment (BOTSEON_MASTER_KEY; see docs/quickstart.md for what losing it costs). The SaaS edition's driver is the same interface over an EU KMS (M2).

A replacement must guarantee: unwrap(orgId, wrap(orgId, dek)) returns dek, for every organisation, indefinitely — this is the one seam where "the interface is satisfied" and "the deployment actually works" are the same claim, since every encrypted row in the database depends on it — and, just as strictly, destroyKek must make every subsequent unwrap for that organisation fail. That second half is not a nice-to-have: it is the mechanism a data subject's data actually gets crypto-shredded by, and a driver that quietly kept the old key recoverable would make an erasure a fiction.

llm

/**
 * One factory for every language-model driver this build carries (F-HOME-5, D150, D151).
 *
 * Before M8 the only drivers were `apps/worker/src/models.ts`' inline `fake` and `ollama` arms,
 * and a third would have been a third `if` in the worker. This file is the one place a
 * `model_routes` row becomes an AI SDK model, so the worker, the settings tab's Test and anything
 * later that needs a model all build it the same way and from the same credential.
 *
 * **Every endpoint is a constant, with two stated exceptions.** `createAnthropic` and
 * `createOpenAI` reach their vendor's own default base URL; the gateway's is `OPENROUTER_BASE_URL`
 * from `@botseon/core`'s catalogue, and it is imported rather than restated so there is exactly
 * one place in the tree that says where a gateway call goes. `ollama` takes a base URL because it
 * is the operator's own `OLLAMA_URL` — a machine they run. `mistral` takes one because the vendor
 * publishes regional endpoints this repository has not verified, and a made-up hostname on an
 * `eu_guaranteed` route would be a claim about where data lands; it falls back to
 * `MISTRAL_DEFAULT_BASE_URL`, the vendor's own default. Both are values an operator states, never
 * values a route or a vendor response can move.
 *
 * **`vertex` is Claude on Google Cloud, and the only arm whose endpoint is a *region*.** Its host
 * is `<location>-aiplatform.googleapis.com`, so the location the caller passes is the whole of
 * where the inference happens — which is why the seeded `vertex-eu:*` routes are the ones that
 * resolve for an organisation on `eu_only` (`packages/models/src/resolve.ts`) and why the worker
 * refuses a non-EU location on the hosted edition before it ever reaches here
 * (`apps/worker/src/env.ts`). This arm reads no API key: Google authenticates a service account,
 * and the credential carries the decoded service-account JSON rather than a path to it (nothing in
 * a container image or a Fly secret is a file).
 *
 * **`fake` is deliberately absent.** The worker refuses `fake` itself, behind its own flag, so
 * that a packaged build cannot answer a member with a scripted string. An unknown provider throws,
 * which is the fail-closed direction.
 *
 * **No key, no model.** Every hosted provider throws `ProviderCredentialMissingError` rather
 * than building a model that will fail at the first call with a vendor's 401 — the difference is
 * between a member reading F-HOME-5's remedy and a member reading someone else's error body.
 */
export function createLanguageModelFor(input: {
  providerId: string;
  providerModelId: string;
  credential: LlmCredential;
  orgId: string;
  /** Injected transport. Production passes none and each provider uses the global `fetch`; the
   *  suites pass one that fails the test if it is ever called, which is how "no test opens a
   *  socket" is enforced rather than asserted. */
  fetch?: typeof globalThis.fetch;
  /** Injected Google bearer source, for the `vertex` arm alone and for the same reason as `fetch`:
   *  production passes none and the driver signs the service-account key, a suite passes a stub
   *  rather than trying to mint a real token. Ignored by every other arm. */
  vertexTokenSource?: VertexTokenSource;
}): LanguageModel {

Ships: six arms behind one factory — anthropic and openai at their vendors' own default base URLs, openrouter at the gateway's constant OPENROUTER_BASE_URL through @ai-sdk/openai-compatible, vertex (Claude on Google Cloud, @ai-sdk/google-vertex/anthropic, a service-account credential and a location that is the residency; issue #201), mistral (the EU platform route, D41/D150 — also @ai-sdk/openai-compatible, at MISTRAL_DEFAULT_BASE_URL unless an operator names a regional endpoint), and ollama at the operator's own OLLAMA_URL, no key. fake is deliberately absent from this factory, for the reason quoted above.

A replacement must guarantee: every hosted provider throws ProviderCredentialMissingError rather than building a model that would fail at its first call with a vendor's 401 — a member is owed F-HOME-5's remedy, never a vendor's own error body — and an unrecognised providerId throws rather than silently resolving to some other driver.

transcription

export interface TranscriptionDriver {
  id: string;
  /** Returns the recognised text. Throws `TranscriptionError` on a provider failure; the route
   *  handler maps that to a member-facing message and never reflects the provider's body.
   *
   *  `model` (review I2): the resolved route's own `RouteRow.provider_model_id` — the specific
   *  reason this route was chosen over a different provider (the research document's own reason
   *  for Deepgram is Danish quality, which is exactly what the model and the language decide).
   *  Optional so a driver with only one model can ignore it, but the route handler always sends
   *  the one its `resolveRoute` call resolved — never a second, independent choice. */
  transcribe(input: {
    audio: Uint8Array;
    mimeType: string;
    languageHint?: string;
    model?: string;
    apiKey: string;
  }): Promise<{ text: string }>;
}
/** Keyed by the provider id (`RouteRow.provider_id`), so the route the residency policy resolved
 *  is the one this looks up — never a second, independent choice of provider. Only `deepgram`
 *  existed at M1; M8 Task 5 adds `openai` (F-HOME-10) exactly as this seam's own rule says: a new
 *  file plus one line here, never an edit to the seam itself. */
export const TRANSCRIPTION_DRIVERS: Record<string, TranscriptionDriver> = {
  [deepgramDriver.id]: deepgramDriver,
  [openaiTranscriptionDriver.id]: openaiTranscriptionDriver,
};

Ships: two drivers, each keyed by its own id. The Deepgram driver (packages/providers/src/transcription.deepgram.ts) — Danish quality is the research document's own reason for it — and, since M8 Task 5, the OpenAI transcription driver (packages/providers/src/transcription.openai.ts, F-HOME-10). The second arrived exactly as this seam promised: a new file plus one line in this map, with no edit to the seam itself; nothing above transcriptionDriverFor changed.

A replacement must guarantee: transcribe throws TranscriptionError on a provider failure rather than returning a value the route handler could mistake for a real transcript, and it never reflects the provider's own response body back — the route handler maps the error's kind to a member-facing message and nothing more specific ever reaches a member.

The credit meter is not a seventh seam

packages/billing's credit meter is not on this page, on purpose. A provider seam is a stable interface with an interchangeable driver behind it — the point is that two drivers can implement the same shape and either can run underneath. Billing is not that shape: packages/billing's plan interface and meter are Apache-2.0 framework code in their own right, not a slot waiting for a backend, and the SaaS edition's Stripe adapter (M2) is a separate, proprietary package that attaches to that interface from outside rather than swapping in behind it the way a KMS driver swaps in behind KeyStore. D16 draws the licence line exactly there: the framework — including the credit meter itself — is Apache-2.0, and Stripe is packaged apart from it, not folded into a seam whose whole point is that either side of it could be open.

Last verified against build c0f77aa.