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.