@exvio/os-backend-core
v0.4.0
Published
Shared backend building blocks for the Wellous OS hub-and-spoke fleet.
Readme
@exvio/os-backend-core
Shared backend building blocks for the Wellous OS hub-and-spoke fleet.
This package plays the same governance role for backend infrastructure that
@exvio/os-theme plays for frontend UI: one implementation, versioned releases,
and deliberate upgrades in each consumer. It ships TypeScript source because
every current consumer runs on Bun and already type-checks with bundler module
resolution.
Scope law
The package owns infrastructure that has the same contract in multiple Wellous backends. It must not own a product's routes, role model, database schema, migrations, environment names, tenant table catalogue, or domain services.
Dependency direction is one-way:
consumer backend ---> @exvio/os-backend-core
adapters ---> db / redis / logger / config supplied by the appCore modules never import a consumer repository. Infrastructure that needs an application service must be exposed as a factory and receive a narrow adapter.
v0.1 tenant API
The first release owns the shared AsyncLocalStorage tenant context and the
Kysely AST plugin that scopes tenant-aware queries.
import {
TenantFilterPlugin,
bypassTenant,
tenantContext,
withTenant,
} from '@exvio/os-backend-core/tenant'
const db = new Kysely<Database>({
dialect,
plugins: [
new TenantFilterPlugin(TENANT_AWARE_TABLES, {
context: tenantContext,
// Transitional v0.1 policy. Move to `throw` after every non-HTTP entry
// point has an explicit tenant or bypass scope.
onMissingContext: 'passthrough',
}),
// Query profiler goes after the tenant filter so it sees executed SQL.
],
})
await tenantContext.run({ tenantId: requestTenantId, bypass: false }, handleRequest)
await withTenant(jobTenantId, runBackgroundJob)
await bypassTenant(runReviewedCrossTenantOperation)The consumer remains the source of truth for TENANT_AWARE_TABLES. The plugin
only receives that ReadonlySet<string>; it does not know any product schema.
The constructor snapshots the set so later mutation cannot alter the boundary.
Entries are case-sensitive bare table names. A tenant-scoped table migration or
rename must update the consumer catalogue and its sentinel test in the same PR.
Security contract
- HTTP tenant middleware must wrap the entire downstream request in
tenantContext.run(...). - WebSockets, background jobs, cron, queues, workers, and fire-and-forget tasks
must use
withTenant(...)for each tenant; HTTP context is not a substitute for an explicit non-HTTP entry boundary.withTenantaccepts only positive safe-integer tenant ids. bypassTenant(...)is only for reviewed cross-tenant operations.- No context currently preserves the fleet's existing behaviour: the plugin
passes the query through by default. Consumers must explicitly configure the
transitional policy, audit every entry point, and then switch to
onMissingContext: 'throw'. - Raw
sql\...`` fragments are opaque to Kysely's AST transformer and must include their own tenant predicate. - Joined tenant tables are currently supported for INNER and LEFT joins
(including their lateral forms). RIGHT, FULL, CROSS, USING-join, and APPLY
variants fail loudly when the joined side is tenant-aware because placing a
predicate in
ONwould not safely filter their preserved side. DELETE ... USINGtenant tables are scoped in the outerWHERE.- A tenant-scoped
UPDATEcannot assigntenant_id. Multi-table targets and dynamic/raw update-column names fail loudly because the package cannot prove their ownership semantics. Ownership changes require a separately reviewed cross-tenant operation. INSERT ... SELECTandINSERT ... DEFAULT VALUESagainst a tenant-aware table fail loudly. The only escape is a narrowly scopedbypassTenant(...)operation that explicitly guarantees the projected tenant id.- UPSERT and REPLACE variants fail loudly for tenant-aware targets. Until a
conflict key can be proven to include
tenant_id, they can otherwise match and mutate another tenant's row through a globally unique key. - With an active tenant context,
MERGEcurrently fails loudly for every table because the Kysely AST cannot yet be rewritten safely. - Compile and execute a query inside the intended context; do not compile it in one scope and execute it in another.
All context users and TenantFilterPlugin must resolve the same installed
package instance. Do not mix a copied tenant context with the packaged plugin.
The optional context constructor argument makes this dependency explicit and
protects linked development/HMR from using a different singleton instance.
tenantContext is exposed for compatibility with HTTP middleware. Application
code must not call its enterWith() or disable() methods; prefer
withTenant(), bypassTenant(), and getCurrentTenant().
Migration and seed commands must make their policy explicit. During the v0.1
transition they may rely on passthrough; before enabling strict mode, move
them to a dedicated Kysely instance without the tenant plugin or wrap each
reviewed operation in the appropriate scope.
v0.2 AI runtime (alpha)
The AI subpath centralizes provider transport and runtime behaviour without owning a product's prompts, tools, tier rules, RAG, workflows, config tables, or usage ledger.
import { createAIClient } from '@exvio/os-backend-core/ai'
import { createDeepSeekProvider } from '@exvio/os-backend-core/ai/providers/deepseek'
import { createGeminiProvider } from '@exvio/os-backend-core/ai/providers/gemini'
import { getCurrentTenant } from '@exvio/os-backend-core/tenant'
const ai = createAIClient({
providers: new Map([
['gemini', createGeminiProvider()],
['deepseek', createDeepSeekProvider()],
]),
// The app still owns DB/Redis config and must keep this tenant-isolated.
configSource: {
loadConfig: tenantId => loadAIConfig(tenantId, { fallback: false }),
},
tenantResolver: () => {
const context = getCurrentTenant()
return context && !context.bypass ? context.tenantId : undefined
},
missingTenantPolicy: 'throw',
telemetry: observeRedactedInvocation,
})
const result = await ai.generateText('Rewrite this clearly.', {
tenantId,
lane: 'text',
metadata: { feature: 'editor.rephrase' },
})
// A reliable product ledger must use the returned result, not telemetry.
await recordUsage({
tenantId,
provider: result.provider,
model: result.model,
usage: result.usage,
})The application config adapter returns explicit provider/model pairs:
{
providers: {
gemini: { apiKey: '...' },
deepseek: {
apiKey: '...',
endpoint: 'https://api.deepseek.com/chat/completions',
},
},
lanes: {
text: { provider: 'gemini', model: 'gemini-2.5-flash' },
vision: { provider: 'gemini', model: 'gemini-2.5-flash' },
chat: { provider: 'deepseek', model: 'deepseek-v4-flash' },
embedding: { provider: 'gemini', model: 'gemini-embedding-001' },
},
}There is no model-prefix inference or silent provider fallback in the new API.
parseAIConfig() also applies Core's built-in model lifecycle policy to every
named lane, including custom lanes and explicit { provider, model } entries.
Retired identifiers fail at config load; only an explicit forward data
migration may use replacementForRetiredAIModelId() to rewrite stored config.
Credentials are loaded per invocation and provider instances are not shared
between tenants. Missing tenant context fails by default. The built-in tenant
resolver intentionally ignores a bypassTenant(...) scope; a reviewed
cross-tenant operation must pass a positive tenantId on every AI call. A
positive explicit tenant must match a normal active tenant context; callers
cannot use an option to escape request tenancy. Enter a reviewed bypass scope
before making an intentional cross-tenant call. A
custom endpoint must be HTTPS, contain no URL credentials, and pass the
application's validateEndpoint policy before an Authorization header is
created. A provider factory's immutable official endpoint is the only endpoint
accepted without that hook. The built-in Gemini adapter additionally accepts
only Google's official API origin.
AIClientOptions.fetch is an injectable transport for HTTP providers such as
DeepSeek. The production Gemini adapter uses the official @google/genai SDK,
which cannot honor that injected transport; it fails loudly if the client is
configured with a custom fetch instead of silently bypassing the application's
transport policy.
Every text, content, chat, structured-output, stream, and embedding result uses
the same actual-model and token-usage envelope. A stream may retry only before
its first emitted event. Tool arguments and structured JSON fail closed rather
than becoming {}. Telemetry is scalar, secret-free, non-blocking, and
best-effort: a sink failure never changes a successful AI result, and telemetry
must not be used as a reliable billing or usage ledger. Persist non-stream usage
from the returned AIResult; for streaming, persist the result carried by the
terminal finish event.
Adding providers and lanes
The registry and lane names are open strings, so adding OpenAI, Anthropic,
Azure AI, an on-prem model, or a future vendor does not require changing the
consumer API. Implement an AIProviderFactory, declare its capabilities, add
provider conformance tests, register it in each application's config adapter,
and add an explicit { provider, model } selection. Product call sites remain
on generateText, generateContent, generateChat, streamChat,
generateObject, or embed.
Multiple providers and lanes can be active for the same tenant at the same
time. For example, text may use DeepSeek, vision Gemini, chat a private
provider, and embedding a dedicated embedding model. New product-specific
lanes such as workflow, classification, or assessment require only a new
config entry; Core does not maintain a closed lane enum. Automatic fallback,
load balancing, or cost routing must be an explicit application policy and may
never silently change the reported provider/model.
Model/lane selection and prices are deliberately application-owned; rates are
not bundled. Use createPriceCatalog() with an operations-owned
version/effective date and calculateCostUsd(). Unknown models return null,
not a misleading zero-dollar value. Product prompts, personas, RAG/vector-store
policy, tool execution/authorization, and workflows also remain outside core.
v0.3 Changelog runtime (alpha)
The Changelog subpath centralizes immutable release-note catalogues, weighted language negotiation, and per-subject acknowledgement state without owning an application's content, HTTP routes, user table, or migrations.
import {
createChangelogCatalogue,
createChangelogService,
} from '@exvio/os-backend-core/changelog'
const catalogue = createChangelogCatalogue({
entries: appReleaseNotes, // newest first; versions are opaque keys
locales: ['en', 'cn', 'bm'] as const,
defaultLocale: 'en',
languageTags: { zh: 'cn', ms: 'bm' },
maxVersionLength: 64, // must match the consumer database column
})
const changelog = createChangelogService({
catalogue,
acknowledgements: appAcknowledgementStore,
})
const state = await changelog.getState({
subjectId: authenticatedUserId,
acceptLanguage: request.headers.get('Accept-Language'),
})The first array entry is the latest release. Core never sorts versions by SemVer, text, or display date. Every acknowledgement must name an exact known version, so a late acknowledgement for an older screen may make a newer release reappear but can never silently hide it. The catalogue snapshots and freezes consumer input, requires the default-locale body, and allows older entries to fall back when an optional translation is absent.
The acknowledgement store always receives both tenantId and subjectId.
The default resolver reads the package tenant context, ignores bypass scopes,
and fails when no tenant exists. An explicit tenant is allowed for reviewed
background or bypass work, but it may not disagree with an active non-bypass
tenant. Consumer repositories should use the supplied subject as their scope;
they must not query by user id alone.
Hono status codes, request-body limits, authentication, Kysely queries,
database migrations, release-note Markdown, and frontend sanitization remain
application-owned. HTTP adapters should project only the documented response
fields and send personalized responses with Cache-Control: no-store, private.
v0.3 Guide runtime (alpha)
The Guide subpath owns the common file-content mechanics used by every backend:
manifest validation, deterministic grouping, locale fallback, bounded Markdown
reads, frontmatter parsing, visibility policies, and removal of private
ai-context authoring comments.
import path from 'node:path'
import {
createFileGuideContentSource,
createGuideService,
} from '@exvio/os-backend-core/guide'
const guides = createGuideService({
locales: ['en', 'cn'] as const,
defaultLocale: 'en',
categories: appGuideCategories,
entries: appGuideEntries,
source: createFileGuideContentSource({
root: path.resolve('src/data/guides'),
maxFileBytes: 512_000,
}),
// These are separate by design: hiding an item in a catalogue is not
// automatically authorization for its detail route.
listPolicy: (entry, context) => includeGuideInCatalogue(entry, context),
readPolicy: (entry, context) => mayReadGuide(entry, context),
})
await guides.validate() // CI/startup asset audit
const document = await guides.load({ category, slug, locale, context })Category, slug, and locale identifiers are validated before file access. The file source requires an explicit absolute root, resolves real paths, rejects resolved symlink escapes, and caps reads. The root must be a Git/deployment-owned read-only asset directory; this adapter is not a sandbox for a directory that an untrusted process can rename or rewrite concurrently. Manifest input is deeply snapshotted and case-insensitive filesystem collisions fail at construction. Locale fallback returns both the requested and actual resolved locale.
Frontmatter intentionally supports only title and description, handles BOM,
LF, and CRLF, and rejects duplicate or unknown fields. User-facing bodies always
remove <!-- ai-context ... -->; an unclosed marker removes the rest of the
document rather than leaking private authoring material. The first API does not
offer a raw or bulk AI loader.
Applications retain their Markdown, manifest metadata, role model, Hono/i18n
adapter, MCP and AI prompt adapters, and any tenant-specific overrides. Core
policies receive the app's generic context but do not know UserRole. Routes
must explicitly project response fields instead of serializing the returned
extensible manifest entry. Markdown-to-HTML rendering and DOM sanitization are
a frontend/theme responsibility.
v0.4 Auth runtime (alpha)
The first Auth slice centralizes framework-neutral login policy, atomic OAuth transactions, non-bearer session-management handles, lifetime calculation, and secret redaction. It does not own users, roles, Hono routes, Redis, Kysely, provider credentials, or product authorization.
import {
createLoginPolicy,
createOAuthBrowserExchangeManager,
createOAuthStateManager,
createSessionBearerToken,
createSessionManagementHandle,
} from '@exvio/os-backend-core/auth'
const policy = createLoginPolicy({
mode: tenantIsHubManaged ? 'hub_managed' : 'standalone',
enabled: { email: true, google: true, microsoft: false, passkey: true },
})
policy.assertAllowed({ surface: 'google', phase: 'start' })
const oneTimeStore = {
put: (key, value, ttlSeconds) => redis.set(key, value, ttlSeconds),
// This must be one Redis GETDEL command or one Lua script.
take: key => redis.getdel(key),
}
const oauthState = createOAuthStateManager({ store: oneTimeStore })
const browserExchange = createOAuthBrowserExchangeManager({ store: oneTimeStore })
const state = await oauthState.issue({
tenantId,
provider: 'google',
redirectUri,
verifier: pkceVerifier,
initiatingSessionId: browserTransactionCookie,
})
const exchangeCode = await browserExchange.issue({
tenantId,
browserTransaction: browserTransactionCookie,
provider: 'google',
userId,
// Optional revocation epoch captured before provider authentication.
sessionGeneration,
})The OAuth manager generates the state token and stores only its SHA-256-derived
key. Consumption binds the exact tenant, provider, redirect URI, and optional
initiating session, then burns the record before returning the PKCE verifier.
Missing, malformed, expired, replayed, tenant-swapped, provider-swapped, and
redirect-swapped transactions fail with a secret-free AuthError.
After a successful provider callback, the browser exchange manager creates a short-lived, one-time code bound to the tenant, browser transaction, provider, user, optional session revocation generation, and expiry. It stores only a SHA-256-derived key and atomically burns the record before comparing the tenant and browser binding, so mismatch, replay, and concurrent consumption fail closed. The application owns the Secure, HttpOnly, SameSite=Lax transaction cookie, Hono exchange endpoint, final login policy/user checks, and session creation. A callback redirects with only the exchange code; a bearer token, PKCE verifier, OAuth state, and user projection must never be placed in the URL.
Passkey alpha boundary
The Auth alpha also provides framework-neutral Passkey challenge transactions,
distributed rate-limit decisions, recent-auth evaluation, HMAC-derived WebAuthn
user handles, and signature-counter compare-and-swap rules. Registration binds
the positive tenant and user ids, initiating session, recent authentication
time, RP ID, origin, and excluded credentials. Authentication binds the
positive tenant id, optional user, RP ID, origin, and allowed credential set.
Both flows consume stored state atomically before comparison; missing, replayed,
concurrent, malformed, purpose-swapped, or context-swapped finishes fail with a
secret-free PasskeyAuthError.
Core supplies no Redis or SQL client. A challenge adapter must implement atomic
take with Redis GETDEL (or one equivalent Lua command), and authentication
issuance also requires atomic putIfAbsent. The application supplies the Redis
Lua rate-limit adapter and tenant-aware SQL counter CAS, plus Hono routes,
SimpleWebAuthn verification, RP configuration, trusted-proxy address policy,
credential persistence, recent-auth UX, security notifications, and durable
audit storage. Durable Passkey audit is not implemented by this alpha and is a
release blocker wherever policy or compliance requires it.
createSessionManagementHandle() derives a stable smh_v1_... handle for a
session-management screen. That handle cannot authenticate a request. A
consumer must still verify the authenticated owner before revocation and must
never return or log the underlying bearer. New sessions use the disjoint
sat_v1_... namespace from createSessionBearerToken(); raw random values,
smh_v1_... handles, and oas_v1_... states are not session bearers. Consumers
enforce both idle and absolute expiry with remainingSessionTtl() and revoke
the request's previous session after every successful authentication or
privilege-changing rotation.
The mode and surface matrix is shared, but role hierarchies are not. Each product's action permissions, object access, workflow ownership, entitlements and API-key/MCP scope catalogues remain application policy.
redactAuthSecrets() and redactAuthStorageKey() produce log-safe values. The
storage-key redactor bundles only generic auth namespaces (session:,
oauth:state:, passkey:auth:, password:reset: and similar); an
application's own credential-bearing key prefixes are passed on each call, for
example redactAuthStorageKey(key, ['ticket:', 'invite:']). A key that matches
no prefix is returned unchanged, so an unlisted namespace is a logging leak, not
a redaction.
Install
The package is on the public npm registry under the @exvio scope. No git, ssh
or AWS credentials are needed on the consumer side, and a Docker build that runs
bun install needs nothing beyond registry access: no Git, no SSH client, no key
to forward.
bun add @exvio/os-backend-core{ "dependencies": { "@exvio/os-backend-core": "^0.4.0" } }Pin exactly (0.4.0, no caret) where a consumer must not pick up a minor: on a
0.x line every minor may carry a breaking change.
Published versions are immutable and every consumer upgrades explicitly. A
pre-release version (0.5.0-alpha.1) is published under the next dist-tag and
never moves latest. Contributor workflow, gates and the release procedure are
in the source repository's CONTRIBUTING.md, which is not part of the package.
