@happyvertical/smrt-app-runtime
Reusable infrastructure composition for s-m-r-t applications. The package
implements the validated profiles from @happyvertical/smrt-config without
forking domain objects, generated surfaces, effects, approvals, or job calls.
Private local applications
import {
initializeLocalApplicationRuntime,
} from '@happyvertical/smrt-app-runtime';
const { runtime, bootstrap, diagnostics } =
await initializeLocalApplicationRuntime({
appId: 'my-app',
sourceRoot: process.cwd(),
prepareDatabase: runApplicationMigrations,
});
// Show bootstrap.token only in the loopback onboarding URL. The plaintext is
// returned once and only its HMAC is persisted.
Application migrations run through the explicit prepareDatabase hook while
the runtime holds the root lease. A rejected migration is normalized into a
LocalRuntimeError with the stable migration_failed code and the fixed,
secret-free message exported as MIGRATION_FAILED_MESSAGE: "The application
migration step failed; run pnpm app:setup and inspect the private migration
logs." The migration driver's own text — a likely carrier of a connection
string, credential, path, or environment value — is never surfaced; it is kept
as a non-enumerable cause for private logs. Startup releases the database
handle and the initialization lease, so the same application root and data
directory remain retryable once the migration is fixed.
The path resolver selects the current user's OS application-data directory
(~/Library/Application Support on macOS, %LOCALAPPDATA% on Windows, or
$XDG_DATA_HOME / ~/.local/share on Linux). Secure initialization currently
requires Node to expose nonzero O_NOFOLLOW and O_DIRECTORY filesystem
flags; it fails closed on platforms without that custody support and therefore
does not yet claim Windows runtime support. The data root contains a mode-0600
SQLite database, a user-owned asset directory, and generated mode-0600
application secret material. The root must be a dedicated application
directory: placing it inside the source checkout or choosing an ancestor is
refused, as is choosing the user home itself or the filesystem root. An
explicitly configured root that already exists must already be
owned by the current user with mode 0700; initialization rejects it without
changing permissions or creating artifacts when that custody proof fails.
An empty root is claimed with an app-specific, empty mode-0600 marker. A
populated root is accepted only with that valid marker, so selecting an
unrelated private directory fails without changing its contents. A pending
marker makes the claim crash-recoverable while the database is first acquired;
it is promoted atomically after the released SQL custody boundary verifies the
complete ancestor/root chain, including macOS ACLs. Failed custody removes the
pending claim and every directory only when they were created by that attempt;
an inherited pending claim and its database remain authoritative for retry.
Initializers for the same data root are serialized across processes by an
exclusive transaction in a dedicated SQLite lock database under a private,
root-keyed custody directory kept beside the data it protects:
<parent of the data root>/.smrt-lease-<key>/initialization.sqlite. That
parent has already passed the custody preflight and is the one location every
process sharing the data directory sees, so the lease also works inside a user
namespace (agent sandboxes, rootless containers) where /tmp reads as owned by
the overflow uid. The historical per-user /tmp/.smrt-<uid>/<key> lease is
still taken afterwards, so a process running an older release (which takes
only that lease) stays excluded. When /tmp fails the ancestry owner check
because its owner is unmapped, the legacy lease is taken lock-only: the lock
file is never written, every ancestor must still be a real directory that is
not group/world writable unless sticky, and the lock directories and file must
be current-user-owned and private. A held legacy lease therefore makes the new
process wait (and fail closed after two minutes). Inside a user namespace that
remaps the uid (for example unshare -Ur, where host uid 1000 reads as 0), the
legacy lease is taken for both the in-namespace uid (/tmp/.smrt-0, what an
older release inside the namespace locks) and, in that order, the uid's mapping
in /proc/self/uid_map (/tmp/.smrt-<host uid>, what an older host process
locks); identical paths are taken once, so a host process takes the single
lease it always did. Every new process takes the beside-data lease first, which
is what keeps the fixed legacy order deadlock-free. When the in-namespace uid
differs from its mapping and its /tmp/.smrt-<uid> root already exists as a
real directory owned by someone else (under unshare -Ur, host root's
/tmp/.smrt-0 reads as the overflow uid), that one candidate is skipped: no
process in the namespace can hold a lease beneath a root it does not own. A
symlink there, and any problem with the mapped candidate, still fails closed.
A map that
cannot be read, parsed, or does not cover the current uid fails closed. Only
one namespace level is visible, so a doubly nested remap is not translated
past its parent. The residual rule: when the
legacy lock directory cannot be created at all (/tmp not writable or
read-only in that view) and no legacy lock file exists, the legacy lease is
skipped. When the data root's parent is not writable by the current user (or is
read-only) the secure /tmp lease is required instead, as before. The
released @happyvertical/sql
trusted-parent boundary validates each lease directory, its ancestors, and the lock
leaf for ownership, write permissions, static links, and macOS ACLs before it
opens the lock database. Atomic SQLite locking elects one owner without deleting
or replacing a pathname, so there is no stale-file ABA window and no PID or
clock lease. Process or worker death releases the kernel lock automatically. A
read-only path, custody, and ownership-marker preflight rejects an obviously
invalid application root before creating its lock-registry entry; the complete
checks run again under the lease before application-root mutation. The lease
covers storage acquisition, secret publication, SQLite tuning,
application migrations, and bootstrap construction. Contenders wait up to two
minutes for that complete sequence, so normal migrations can finish without
overlap. Marker or temporary-secret cleanup therefore cannot race another
active initializer.
Every existing path component is opened without following symbolic links and
checked against its canonical path before descendants or secret bytes are
written. The runtime then acquires SQLite through @happyvertical/sql's
explicit node:sqlite trusted-parent custody boundary, rooted at the mode-0700
application data directory. Unsupported runtimes or platforms and unsafe
ownership, permissions, ACLs, or path components fail closed. The local
runtime.databaseConfig() and default runtime.classOptions() retain that
same driver, custody, and root configuration when application collections open
another connection; an explicit per-class database override still wins.
The application secret is published by atomically linking a fully written, synced mode-0600 temporary file into place. Concurrent installers validate and reuse the one winning value; an incomplete or malformed existing secret is rejected rather than overwritten, and stale interrupted temporary files are removed after a complete value is durably available.
The custody boundary prevents static link traversal and mutation by other OS principals while the application retains control of that directory. Hostile code already running as the same user is outside this boundary; isolating same-account processes requires an OS sandbox and a descriptor-relative SQLite VFS.
Owner onboarding binds to 127.0.0.1 by default and accepts only loopback IP
literals (127.0.0.0/8 or ::1), avoiding hostname-resolution ambiguity.
The first valid claim creates a tenant-bound Person, User, default Tenant,
owner Role / Membership, and server-side Session atomically. The owner
Person belongs to the actual created or adopted tenant; its shared profile type
remains global. Startup and setup are idempotent; replayed, expired, or
concurrent claims fail closed.
ownerTenant: { slug, name, adoptExisting } (also accepted by the SvelteKit
runtime) selects the bootstrap tenant; the default is a new tenant with slug
default. The default tenant, owner User, owner Person, and owner Membership
have separate UUIDv8 identifiers derived with domain-separated HMAC-SHA256
from the installation's private secrets/application.secret. Independent
installs of the same app get distinct identities and share the global built-in
role IDs. Restarts reuse the same installation identities. Treat
secrets/application.secret as installation identity material. Preserve it
with the database in protected backups and during a restore or move of the
same installation. Deleting, replacing, or rotating the file changes the
derived identities and invalidates existing bootstrap and local MCP tokens.
Keep only one active installation for a given secret. A copied data root
retains the same owner, tenant, and writer IDs, and the runtime currently has
no way to detect that copy. Start independent installations with a fresh data
root.
The invitation and active owner membership authorize the owner. The same seed derives a separate writer UUID. The local runtime binds it to the database so local edits to tracked rows identify this installation, including after a restart.
A tenant with the selected slug causes owner_tenant_conflict, unless
adoptExisting: true explicitly allows adopting an active root tenant with
zero memberships of any status. An adopted tenant keeps its id and name.
The claim rejects an unrelated row using the reserved tenant UUID and an
account using the owner UUID or the claimed email. The owner Person and
Membership also use strict inserts. A failed claim rolls back
and leaves the invitation claimable.
Authenticated session TTLs are configured in whole seconds with a minimum of
one second, and invalid values are rejected before filesystem mutation.
Background jobs and application-defined paid capabilities remain disabled until
explicitly enabled. With embedded job topology, createEmbeddedJobRunner()
returns the normal s-m-r-t TaskRunner, so the application keeps one enqueue
and execution contract without requiring a separate worker service.
Local desktop MCP credentials
A desktop product can mount this opt-in SvelteKit route in
src/routes/api/local/mcp-credentials/+server.ts.
import { createLocalMcpCredentialHandler } from '@happyvertical/smrt-app-runtime/sveltekit';
import { runtime } from '$lib/server/smrt';
export const POST = createLocalMcpCredentialHandler(runtime);
Serve it on the desktop app's direct loopback listener, with the adapter's
socket peer address and request Host. Keep forwarded address, host, and
protocol configuration unset. Set adapter-node's
ORIGIN to the actual desktop URL, such as http://127.0.0.1:4678, because
adapter-node otherwise assumes HTTPS. The handler checks the socket peer,
loopback URL host, matching Host and exact Origin. When the browser
sends Sec-Fetch-Site, it must be same-origin. Call the endpoint from the
app's signed-in, same-origin desktop webview using its normal session cookie.
The session must use local bootstrap, password or passkey authentication,
have a direct active membership, and match the sole active system Owner
membership of the installation. Device, layered and permission-capped sessions
are rejected.
Send a JSON POST with an opaque lowercase UUIDv4 id generated by the
desktop app, a nonempty array of permission slugs, and an explicit integer
lifetime from 60 to 31,536,000 seconds.
{
"action": "put",
"id": "2c70f518-b95a-445b-a75e-8126ae7c032a",
"scopes": ["notes.read"],
"expiresInSeconds": 3600
}
The first PUT creates the slot. A later POST with the same action: "put"
replaces its bearer, scopes and expiry atomically. Scopes must be held by
both the live owner and the authenticated session. Successful replies carry
{ id, token, scopes, expiresAt } in a private Cache-Control: no-store
response. Pass the bearer directly to native credential handling for the MCP
bridge. Keep it out of arguments, URLs, logs, browser storage, and generated
plugin artifacts. The installation secret stays on the server, and the
database stores only the bearer HMAC.
Persist the non-secret slot ID before the first request and serialize actions per slot. A failed write preserves the previous credential. If the outcome is unknown or the response is lost, repeat PUT with the same ID. Each successful write produces a fresh credential, and only the latest committed write verifies. A retry therefore recovers the slot without leaving an extra active credential.
Send { "action": "revoke", "id": "<slot-id>" } with an unrestricted owner
session to disconnect. Repeating it returns { "status": "revoked" }.
Revocation is permanent for the slot even if a PUT was delayed or no PUT
completed, so use a new UUID to reconnect. The operator CLI can list or
revoke the stored UUIDv8 record. Desktop slots and manual CLI token IDs are
separate namespaces.
Failures carry a fixed code and no credentials. A revoked slot returns
409 slot_unavailable; validation failures return 400 invalid_request
or 400 invalid_scope; requests failing the trust checks return
403 forbidden. Deployed profiles return 404 unavailable to requests
that reach the profile check.
SvelteKit entry
@happyvertical/smrt-app-runtime/sveltekit composes the profile runtime,
tenant selection, the signed session, and authorized-tenant locals so an app's
server hooks are a few lines. @sveltejs/kit is an optional peer dependency;
the root entry never imports it.
// src/hooks.server.ts
import { createSmrtSvelteKitRuntime } from '@happyvertical/smrt-app-runtime/sveltekit';
export const runtime = createSmrtSvelteKitRuntime({
prepareDatabase: runApplicationMigrations, // optional, idempotent
});
export const { handle, init } = runtime;
// src/app.d.ts
import type { SmrtRuntimeLocals } from '@happyvertical/smrt-app-runtime/sveltekit';
declare global {
namespace App {
interface Locals extends SmrtRuntimeLocals {}
}
}
init is the fail-closed startup gate (local runtime or deployed bindings) and
handle waits for it, then runs, in order: URL tenant selection into
locals.selectedTenant* (never tenant context; tenant headers are ignored),
the signed session, and publication of locals.tenantContext only when the
active context matches the verified session tenant. A session's tenant is
accepted only for an active direct membership or a legitimately inherited
one (isSessionTenantAuthorized(), the same rule deployed restoreSession()
applies); otherwise the request is unauthenticated (no user, permissions,
tenant, or tenant context). database-rls isolation always runs the request
RLS transaction (session.postgresRls cannot disable it); a
session.skipPaths prefix skips session loading only and still runs inside
that transaction as an anonymous principal. During such a request
runtime.classOptions() / runtime.databaseConfig() return the
transaction-bound request database (unless the class has its own db
override), so call them per request and never retain the result. Downstream code runs
at most once: its error propagates unchanged, and a session-layer failure
before it returns 500 with no authenticated locals. Defaults read SMRT_APP_ID, SMRT_DATA_DIR, HOST, DATABASE_URL,
TENANT_BASE_DOMAIN, and SMRT_BACKGROUND_JOBS; smrt.config runtime
selects the profile (local when absent; a present null/false/0/''
fails closed, as in smrt app). Deployed profiles additionally require
providerReadiness probes and fail closed without them. In the local profile
the runtime holds the single-writer lease shared with smrt app operations by
default (defaultWriterLease(): writer.lease under
prepareApplicationStateRoot(), presenting SMRT_OPERATION_INSTANCE); pass
acquireWriterLease to replace it or acquireWriterLease: false to hold none.
Optional hooks: onBootstrapInvitation (present a newly issued setup token),
selectTenant, session, and classOverrides.
runtime.getCollection(className) returns the registered collection built
from runtime.classOptions(className) on that call. Each generated /api/*
route embeds a prelude that imports the app's src/lib/server/smrt.ts and
resolves collections through its exported runtime (core's
createGeneratedCollectionAccess()), so an app's smrt.ts is only the
runtime and its options. A legacy getCollection/getSmrtConfig export there
still takes precedence for one release, with a deprecation warning.
Mountable routes:
// src/routes/api/_runtime/health/+server.ts
export const GET = createRuntimeHealthHandler(runtime);
// src/routes/api/_runtime/diagnostics/+server.ts
export const GET = createRuntimeDiagnosticsHandler({ runtime, toolNames });
// src/routes/setup/+page.server.ts
export const { load, actions } = createOwnerSetupPage(runtime);
// src/routes/+layout.server.ts
export const load = createSessionLayoutLoad();
// src/routes/sign-out/+server.ts
export const POST = createSignOutHandler(runtime);
createSessionLayoutLoad() returns session.user ({ id, email, name? } or
null). createSignOutHandler accepts only same-origin POST (405 / 403
otherwise), destroys the session, clears the cookie, and answers 204 (303 to
/ for HTML form posts).
Diagnostics authorize (owner role or runtime_diagnostics.read on an active,
session-matching membership) before reading the runtime and return only stable
{ schemaVersion: 1, error: { code } } failures. Owner setup is local-only and
re-checks on every request that both the peer address and the URL host are
loopback; its default action reads token, name, email, and optional password and
tenantName, sets the session cookie, and redirects 303, or returns
fail(status, { code, message }) with setup_disabled (404),
setup_unavailable (403), setup_invalid_input (400), setup_invalid
(400), or setup_conflict (409, the ownerTenant slug already exists). Claim error text is never returned. After the claim commits, the
runtime removes the smrt app setup / recover hand-off files
(ONBOARDING_HANDOFF_FILES: onboarding.json, onboarding-launch.html) from
runtime.applicationStateRoot(), so pnpm app:open stops offering the spent
invitation; failed claims leave them untouched. onOwnerClaimed remains for
extra app cleanup and removeOnboardingHandoff: false opts out.
Password-based applications should pass { requirePassword: true } to
createOwnerSetupPage and include a password field in their setup form. The
password is stored in the owner-claim transaction; a rejected password leaves
the token available for retry. Existing bootstrap consumers may omit it.
Use one server-only PasswordPolicyOptions value for bootstrap and sign-in,
including any pepper, length constraints, or custom rejection hook:
const passwordPolicy = { pepper: serverSecrets.passwordPepper, minLength: 16 };
const runtime = createSmrtSvelteKitRuntime({ passwordPolicy });
const passwords = await PasswordCredentialService.create({
...runtime.classOptions('User'),
password: passwordPolicy,
});
initializeLocalApplicationRuntime accepts the same passwordPolicy option.
It is never taken from form data or included in diagnostics. Bootstrap uses the
policy on its transaction-bound service, and registers password-management
permissions before seeding the owner's grants.
resolveApplicationId() and runtimeConfigurationFingerprint() (root entry)
are the canonical app ID and secret-free configuration fingerprint shared by
the web health route and process managers.
The root entry also owns the operator state shared with smrt app:
prepareApplicationStateRoot() (private, app-bound state directory),
withOperationLock(), acquireWriterLease() / readActiveWriterLease(),
and createProviderReadinessProbe(). The SvelteKit runtime takes the writer
lease itself; a SvelteKit app passes only the readiness probe and its own
options, without depending on the CLI:
// src/lib/server/smrt.ts
import { createProviderReadinessProbe } from '@happyvertical/smrt-app-runtime';
import { createSmrtSvelteKitRuntime } from '@happyvertical/smrt-app-runtime/sveltekit';
export const runtime = createSmrtSvelteKitRuntime({
providerReadiness: createProviderReadinessProbe,
});
Self-hosted and cloud applications
The deployed initializer validates the selected profile against concrete, provider-owned bindings. Database URLs, OIDC credentials, storage keys, and secret-manager identifiers stay inside those adapters and are never copied into the runtime snapshot.
import {
initializeDeployedApplicationRuntime,
} from '@happyvertical/smrt-app-runtime';
import { getDatabase } from '@happyvertical/sql';
const initialized = await initializeDeployedApplicationRuntime({
profile: 'self-hosted',
database: {
engine: 'postgres',
connect: () => getDatabase({
type: 'postgres',
url: requirePrivateSetting('DATABASE_URL'),
}),
close: async (db) => {
await db.close?.();
},
},
authentication: {
provider: 'oidc',
readiness: () => oidcProvider.assertReady(),
},
assets: {
provider: 's3-compatible',
readiness: () => assetProvider.assertReady(),
},
secrets: {
provider: 'environment',
readiness: () => secretProvider.assertReady(),
},
prepareDatabase: runApplicationMigrations,
});
Startup validates every binding, including the provider-owned database cleanup
boundary, before opening a connection. A missing public-auth or secret binding,
a selector mismatch, an unavailable provider, a failed
PostgreSQL probe, or a failed migration rejects startup. Provider failures are
reported with stable component codes and omit the underlying provider message
so credentials cannot leak into HTTP or orchestration payloads.
A custom database readiness callback is additive; the runtime always runs its
own PostgreSQL-specific server-version probe before startup or readiness can
succeed.
If cleanup after a startup failure also fails,
DeployedRuntimeCleanupError.retryCleanup() retains the redacted, idempotent
ownership path until the provider closes successfully.
health() reports process liveness. readiness() rechecks PostgreSQL,
authentication, assets, and secrets and returns only ready / not-ready
component states. diagnostics() reports the resolved profile, explicit
provider selectors, tenancy posture, and worker topology without secret values.
Job producers keep using SmrtObject.bg() / background().enqueue() in every
profile. Deployed worker entry points initialize the ordinary runners against
the same PostgreSQL database. A worker is a separate Node process, so it must
first import the application's compiled object registration —
.smrt/runtime/register.js, produced by a SvelteKit smrtPlugin() build — or
any job targeting an application object fails with Unknown object type:
await import('./.smrt/runtime/register.js');
const taskWorker = await initialized.createTaskWorker({ concurrency: 8 });
await taskWorker.start();
// Run this in a separate schedule-worker process, not beside the web server.
const scheduleWorker = await initialized.createScheduleWorker();
await scheduleWorker.start();
initialized.close() drains in-flight readiness/session/worker initialization
and serialized runner start/stop operations, stops every runner returned by
this runtime, and only then closes PostgreSQL.
Callers may stop a runner earlier, but must not restart a returned runner after
its runtime has begun shutdown; lifecycle-gated start() calls then reject.
An awaited close() request made from inside a tracked provider or worker
operation acknowledges shutdown so that operation can unwind; callers outside
the operation continue to await complete worker and database cleanup.
Self-hosted deployments may select OIDC, magic-link, or password authentication, explicit single- or multi-tenancy, local or S3-compatible assets, and environment, local-file, or external secrets. Cloud requires hosted identity, PostgreSQL, multi-tenancy with required tenant context, managed/external secrets, managed/S3-compatible object storage, public TLS, and scalable workers. Cloud may select application isolation instead of PostgreSQL RLS, but it can never enable an unscoped/root-tenant fallback.
The application runtime does not provision databases, buckets, identity
providers, secret managers, worker fleets, TLS, billing, or a hosted control
plane. Those remain operator/managed-platform responsibilities. Enabling the
database-rls selector also requires the deployment migration to apply the
documented s-m-r-t PostgreSQL policies; the selector does not grant or mutate
database privileges at runtime.
Vault recovery key wrapping
The Node-only @happyvertical/smrt-app-runtime/vault-recovery entry can wrap a
32-byte vault data key with a recovery secret held by the user.
wrapVaultRecoveryKey({ keyId, key }, recoverySecret, { accountId, generation })
returns an opaque record. openVaultRecoveryKey(record, recoverySecret, context)
returns the authenticated key ID and key. Use a high-entropy passphrase or a
randomly generated printable recovery code. Keep the secret separate from the
wrapped record. Losing every authorized device and the recovery secret means
losing access to the data. These functions leave storage, device enrollment,
rotation, and revocation to their callers.
Supply the expected account and key generation from trusted local or pairing state. The recovery record cannot establish which account or generation is current. A holder of the wrapped record can try guesses offline, so callers should run recovery locally after user action.
The v1 record starts with ASCII HVRK, the byte 01, and a 16-byte random
salt. The rest is an uncompressed
SDK v1 envelope.
Its plaintext is the 32-byte data key. The record has 195 bytes plus the
UTF-8 byte length of the account ID. Readers reject incompatible lengths and
envelope headers before deriving the wrapping key. The header carries the data key's ID
as 32 lowercase hex digits in the API. The authenticated context uses the
account ID, the fixed device slot smrt-vault-recovery-v1, and the unsigned
64-bit generation as the sequence. The wrapping key is 32 bytes derived
with scrypt (N=131072, r=8, p=1) from the secret and the salt prefixed
with ASCII smrt-vault-recovery-v1. The secret is encoded as 16 to 1024 bytes
of well-formed UTF-8 with no normalization or trimming.
Vault device key wrapping
Use the Node entry @happyvertical/smrt-app-runtime/vault-device-key to wrap an
account data key on a device that already holds it. Pass a distinct, randomly
generated 32-byte device secret for each device. Keep the device secret in local
credential custody and store or transfer only the returned wrapped record.
wrapVaultDeviceKey(dataKey, deviceSecret, context) returns the record.
openVaultDeviceKey(record, deviceSecret, context) returns the 32-byte data key
after authentication. Supply { accountId, deviceId, generation, keyId } from
trusted device state. The generation is an unsigned 64-bit bigint and the key
ID is 32 lowercase hex digits. The caller manages enrollment, storage, and
which generations are current. Removing a wrapped record cannot revoke a key
a device already recovered.
A v1 record starts with ASCII HVDK and the byte 01. The remainder is an
uncompressed SDK v1 envelope.
Its plaintext is the 32-byte data key. Its authenticated context uses the
account ID, device ID, and generation as the snapshot sequence. Its header
carries the data key ID. Derive the 32-byte wrapping key from the device secret
with HKDF-SHA256, an empty salt, and
ASCII smrt-vault-device-key-v1 as the info string.
Preparing a vault key generation
Use prepareVaultKeyGeneration(options) from
@happyvertical/smrt-app-runtime/vault-key-generation on the initiating
device. Supply the account and device IDs, the device's random 32-byte secret,
the user's recovery secret, and the current generation from trusted state.
Pass currentGeneration: null to prepare generation 1. The operation creates
a fresh key and ID, then returns the new context and both wrapped records
after both wraps succeed. The initiating device can open its record with
openVaultDeviceKey. A device using the recovery secret can open the recovery
record with openVaultRecoveryKey.
The result is a candidate for publication. A caller that persists it must commit the records and context together, conditioned on the expected previous generation. Concurrent preparations can produce different candidates for the same next generation. Retain the wrapped keys needed to open existing backups. Device enrollment and revocation have their own authorization and storage requirements.