@happyvertical/smrt-app-runtime 0.60.12 → 0.60.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/manifest.json +1 -1
- package/dist/smrt-knowledge.json +10 -6
- package/dist/smrt-knowledge.okf.md +5 -2
- package/dist/vault-device-key.d.ts +11 -0
- package/dist/vault-device-key.d.ts.map +1 -0
- package/dist/vault-device-key.js +59 -0
- package/dist/vault-device-key.js.map +1 -0
- package/dist/vault-recovery.d.ts +13 -0
- package/dist/vault-recovery.d.ts.map +1 -0
- package/dist/vault-recovery.js +106 -0
- package/dist/vault-recovery.js.map +1 -0
- package/package.json +18 -9
package/README.md
CHANGED
|
@@ -474,3 +474,56 @@ plane. Those remain operator/managed-platform responsibilities. Enabling the
|
|
|
474
474
|
`database-rls` selector also requires the deployment migration to apply the
|
|
475
475
|
documented s-m-r-t PostgreSQL policies; the selector does not grant or mutate
|
|
476
476
|
database privileges at runtime.
|
|
477
|
+
|
|
478
|
+
## Vault recovery key wrapping
|
|
479
|
+
|
|
480
|
+
The Node-only `@happyvertical/smrt-app-runtime/vault-recovery` entry can wrap a
|
|
481
|
+
32-byte vault data key with a recovery secret held by the user.
|
|
482
|
+
`wrapVaultRecoveryKey({ keyId, key }, recoverySecret, { accountId, generation })`
|
|
483
|
+
returns an opaque record. `openVaultRecoveryKey(record, recoverySecret, context)`
|
|
484
|
+
returns the authenticated key ID and key. Use a high-entropy passphrase or a
|
|
485
|
+
randomly generated printable recovery code. Keep the secret separate from the
|
|
486
|
+
wrapped record. Losing every authorized device and the recovery secret means
|
|
487
|
+
losing access to the data. These functions leave storage, device enrollment,
|
|
488
|
+
rotation, and revocation to their callers.
|
|
489
|
+
|
|
490
|
+
Supply the expected account and key generation from trusted local or pairing
|
|
491
|
+
state. The recovery record cannot establish which account or generation is
|
|
492
|
+
current. A holder of the wrapped record can try guesses offline, so callers
|
|
493
|
+
should run recovery locally after user action.
|
|
494
|
+
|
|
495
|
+
The v1 record starts with ASCII `HVRK`, the byte `01`, and a 16-byte random
|
|
496
|
+
salt. The rest is an uncompressed
|
|
497
|
+
[SDK v1 envelope](https://github.com/happyvertical/sdk/blob/v0.111.6/packages/encryption/README.md#vault-snapshot-envelopes).
|
|
498
|
+
Its plaintext is the 32-byte data key. The record has 195 bytes plus the
|
|
499
|
+
UTF-8 byte length of the account ID. Readers reject incompatible lengths and
|
|
500
|
+
envelope headers before deriving the wrapping key. The header carries the data key's ID
|
|
501
|
+
as 32 lowercase hex digits in the API. The authenticated context uses the
|
|
502
|
+
account ID, the fixed device slot `smrt-vault-recovery-v1`, and the unsigned
|
|
503
|
+
64-bit generation as the sequence. The wrapping key is 32 bytes derived
|
|
504
|
+
with scrypt (`N=131072, r=8, p=1`) from the secret and the salt prefixed
|
|
505
|
+
with ASCII `smrt-vault-recovery-v1`. The secret is encoded as 16 to 1024 bytes
|
|
506
|
+
of well-formed UTF-8 with no normalization or trimming.
|
|
507
|
+
|
|
508
|
+
## Vault device key wrapping
|
|
509
|
+
|
|
510
|
+
Use the Node entry `@happyvertical/smrt-app-runtime/vault-device-key` to wrap an
|
|
511
|
+
account data key on a device that already holds it. Pass a distinct, randomly
|
|
512
|
+
generated 32-byte device secret for each device. Keep the device secret in local
|
|
513
|
+
credential custody and store or transfer only the returned wrapped record.
|
|
514
|
+
|
|
515
|
+
`wrapVaultDeviceKey(dataKey, deviceSecret, context)` returns the record.
|
|
516
|
+
`openVaultDeviceKey(record, deviceSecret, context)` returns the 32-byte data key
|
|
517
|
+
after authentication. Supply `{ accountId, deviceId, generation, keyId }` from
|
|
518
|
+
trusted device state. The generation is an unsigned 64-bit bigint and the key
|
|
519
|
+
ID is 32 lowercase hex digits. The caller manages enrollment, storage, and
|
|
520
|
+
which generations are current. Removing a wrapped record cannot revoke a key
|
|
521
|
+
a device already recovered.
|
|
522
|
+
|
|
523
|
+
A v1 record starts with ASCII `HVDK` and the byte `01`. The remainder is an
|
|
524
|
+
uncompressed [SDK v1 envelope](https://github.com/happyvertical/sdk/blob/v0.111.6/packages/encryption/README.md#vault-snapshot-envelopes).
|
|
525
|
+
Its plaintext is the 32-byte data key. Its authenticated context uses the
|
|
526
|
+
account ID, device ID, and generation as the snapshot sequence. Its header
|
|
527
|
+
carries the data key ID. Derive the 32-byte wrapping key from the device secret
|
|
528
|
+
with [HKDF-SHA256](https://www.rfc-editor.org/rfc/rfc5869), an empty salt, and
|
|
529
|
+
ASCII `smrt-vault-device-key-v1` as the info string.
|
package/dist/manifest.json
CHANGED
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,21 +3,24 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-app-runtime",
|
|
6
|
-
"packageVersion": "0.60.
|
|
6
|
+
"packageVersion": "0.60.13",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
10
|
+
"manifest": "8f419c57bc7a653725835655092fa7a5af8a10dc57de544877be256dc2fad922",
|
|
11
|
+
"packageJson": "bf5facc4ba6d9fb3468670086361cb2ca481b253fcf2663bfea802d1876f50e8",
|
|
12
12
|
"agents": "dd8582810666f6fbeccf1ca862f443400c163d16ff94e887bddc9fdca6927b01",
|
|
13
|
-
"moduleDoc:README.md": "
|
|
13
|
+
"moduleDoc:README.md": "fb4fd4ab0cf3e93c3195461787cd166e2f4b291c11fa25bf58f81a7a6ed695b1"
|
|
14
14
|
},
|
|
15
15
|
"exports": [
|
|
16
16
|
".",
|
|
17
17
|
"./sveltekit",
|
|
18
|
-
"./sveltekit-runtime"
|
|
18
|
+
"./sveltekit-runtime",
|
|
19
|
+
"./vault-device-key",
|
|
20
|
+
"./vault-recovery"
|
|
19
21
|
],
|
|
20
22
|
"dependencies": {
|
|
23
|
+
"@happyvertical/encryption": "catalog:",
|
|
21
24
|
"@happyvertical/smrt-config": "workspace:*",
|
|
22
25
|
"@happyvertical/smrt-core": "workspace:*",
|
|
23
26
|
"@happyvertical/smrt-jobs": "workspace:*",
|
|
@@ -43,6 +46,7 @@
|
|
|
43
46
|
"@happyvertical/smrt-vitest"
|
|
44
47
|
],
|
|
45
48
|
"sdkDependencies": [
|
|
49
|
+
"@happyvertical/encryption",
|
|
46
50
|
"@happyvertical/sql"
|
|
47
51
|
],
|
|
48
52
|
"tags": [
|
|
@@ -73,7 +77,7 @@
|
|
|
73
77
|
{
|
|
74
78
|
"path": "README.md",
|
|
75
79
|
"module": "README",
|
|
76
|
-
"content": "# @happyvertical/smrt-app-runtime\n\nReusable infrastructure composition for s-m-r-t applications. The package\nimplements the validated profiles from `@happyvertical/smrt-config` without\nforking domain objects, generated surfaces, effects, approvals, or job calls.\n\n## Private local applications\n\n```ts\nimport {\n initializeLocalApplicationRuntime,\n} from '@happyvertical/smrt-app-runtime';\n\nconst { runtime, bootstrap, diagnostics } =\n await initializeLocalApplicationRuntime({\n appId: 'my-app',\n sourceRoot: process.cwd(),\n prepareDatabase: runApplicationMigrations,\n });\n\n// Show bootstrap.token only in the loopback onboarding URL. The plaintext is\n// returned once and only its HMAC is persisted.\n```\n\nApplication migrations run through the explicit `prepareDatabase` hook while\nthe runtime holds the root lease. A rejected migration is normalized into a\n`LocalRuntimeError` with the stable `migration_failed` code and the fixed,\nsecret-free message exported as `MIGRATION_FAILED_MESSAGE`: \"The application\nmigration step failed; run pnpm app:setup and inspect the private migration\nlogs.\" The migration driver's own text — a likely carrier of a connection\nstring, credential, path, or environment value — is never surfaced; it is kept\nas a non-enumerable `cause` for private logs. Startup releases the database\nhandle and the initialization lease, so the same application root and data\ndirectory remain retryable once the migration is fixed.\n\nThe path resolver selects the current user's OS application-data directory\n(`~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on Windows, or\n`$XDG_DATA_HOME` / `~/.local/share` on Linux). Secure initialization currently\nrequires Node to expose nonzero `O_NOFOLLOW` and `O_DIRECTORY` filesystem\nflags; it fails closed on platforms without that custody support and therefore\ndoes not yet claim Windows runtime support. The data root contains a mode-0600\nSQLite database, a user-owned asset directory, and generated mode-0600\napplication secret material. The root must be a dedicated application\ndirectory: placing it inside the source checkout or choosing an ancestor is\nrefused, as is choosing the user home itself or the filesystem root. An\nexplicitly configured root that already exists must already be\nowned by the current user with mode `0700`; initialization rejects it without\nchanging permissions or creating artifacts when that custody proof fails.\nAn empty root is claimed with an app-specific, empty mode-0600 marker. A\npopulated root is accepted only with that valid marker, so selecting an\nunrelated private directory fails without changing its contents. A pending\nmarker makes the claim crash-recoverable while the database is first acquired;\nit is promoted atomically after the released SQL custody boundary verifies the\ncomplete ancestor/root chain, including macOS ACLs. Failed custody removes the\npending claim and every directory only when they were created by that attempt;\nan inherited pending claim and its database remain authoritative for retry.\nInitializers for the same data root are serialized across processes by an\nexclusive transaction in a dedicated SQLite lock database under a private,\nroot-keyed custody directory kept beside the data it protects:\n`<parent of the data root>/.smrt-lease-<key>/initialization.sqlite`. That\nparent has already passed the custody preflight and is the one location every\nprocess sharing the data directory sees, so the lease also works inside a user\nnamespace (agent sandboxes, rootless containers) where `/tmp` reads as owned by\nthe overflow uid. The historical per-user `/tmp/.smrt-<uid>/<key>` lease is\nstill taken afterwards, so a process running an older release (which takes\nonly that lease) stays excluded. When `/tmp` fails the ancestry owner check\nbecause its owner is unmapped, the legacy lease is taken lock-only: the lock\nfile is never written, every ancestor must still be a real directory that is\nnot group/world writable unless sticky, and the lock directories and file must\nbe current-user-owned and private. A held legacy lease therefore makes the new\nprocess wait (and fail closed after two minutes). Inside a user namespace that\nremaps the uid (for example `unshare -Ur`, where host uid 1000 reads as 0), the\nlegacy lease is taken for both the in-namespace uid (`/tmp/.smrt-0`, what an\nolder release inside the namespace locks) and, in that order, the uid's mapping\nin `/proc/self/uid_map` (`/tmp/.smrt-<host uid>`, what an older host process\nlocks); identical paths are taken once, so a host process takes the single\nlease it always did. Every new process takes the beside-data lease first, which\nis what keeps the fixed legacy order deadlock-free. When the in-namespace uid\ndiffers from its mapping and its `/tmp/.smrt-<uid>` root already exists as a\nreal directory owned by someone else (under `unshare -Ur`, host root's\n`/tmp/.smrt-0` reads as the overflow uid), that one candidate is skipped: no\nprocess in the namespace can hold a lease beneath a root it does not own. A\nsymlink there, and any problem with the mapped candidate, still fails closed.\nA map that\ncannot be read, parsed, or does not cover the current uid fails closed. Only\none namespace level is visible, so a doubly nested remap is not translated\npast its parent. The residual rule: when the\nlegacy lock directory cannot be created at all (`/tmp` not writable or\nread-only in that view) and no legacy lock file exists, the legacy lease is\nskipped. When the data root's parent is not writable by the current user (or is\nread-only) the secure `/tmp` lease is required instead, as before. The\nreleased `@happyvertical/sql`\ntrusted-parent boundary validates each lease directory, its ancestors, and the lock\nleaf for ownership, write permissions, static links, and macOS ACLs before it\nopens the lock database. Atomic SQLite locking elects one owner without deleting\nor replacing a pathname, so there is no stale-file ABA window and no PID or\nclock lease. Process or worker death releases the kernel lock automatically. A\nread-only path, custody, and ownership-marker preflight rejects an obviously\ninvalid application root before creating its lock-registry entry; the complete\nchecks run again under the lease before application-root mutation. The lease\ncovers storage acquisition, secret publication, SQLite tuning,\napplication migrations, and bootstrap construction. Contenders wait up to two\nminutes for that complete sequence, so normal migrations can finish without\noverlap. Marker or temporary-secret cleanup therefore cannot race another\nactive initializer.\nEvery existing path component is opened without following symbolic links and\nchecked against its canonical path before descendants or secret bytes are\nwritten. The runtime then acquires SQLite through `@happyvertical/sql`'s\nexplicit `node:sqlite` trusted-parent custody boundary, rooted at the mode-0700\napplication data directory. Unsupported runtimes or platforms and unsafe\nownership, permissions, ACLs, or path components fail closed. The local\n`runtime.databaseConfig()` and default `runtime.classOptions()` retain that\nsame driver, custody, and root configuration when application collections open\nanother connection; an explicit per-class database override still wins.\n\nThe application secret is published by atomically linking a fully written,\nsynced mode-0600 temporary file into place. Concurrent installers validate and\nreuse the one winning value; an incomplete or malformed existing secret is\nrejected rather than overwritten, and stale interrupted temporary files are\nremoved after a complete value is durably available.\n\nThe custody boundary prevents static link traversal and mutation by other OS\nprincipals while the application retains control of that directory. Hostile\ncode already running as the same user is outside this boundary; isolating\nsame-account processes requires an OS sandbox and a descriptor-relative SQLite\nVFS.\n\nOwner onboarding binds to `127.0.0.1` by default and accepts only loopback IP\nliterals (`127.0.0.0/8` or `::1`), avoiding hostname-resolution ambiguity.\nThe first valid claim creates a tenant-bound `Person`, `User`, default `Tenant`,\nowner `Role` / `Membership`, and server-side `Session` atomically. The owner\nPerson belongs to the actual created or adopted tenant; its shared profile type\nremains global. Startup and setup are idempotent; replayed, expired, or\nconcurrent claims fail closed.\n`ownerTenant: { slug, name, adoptExisting }` (also accepted by the SvelteKit\nruntime) selects the bootstrap tenant; the default is a new tenant with slug\n`default`. The default tenant, owner User, owner Person, and owner Membership\nhave separate UUIDv8 identifiers derived with domain-separated HMAC-SHA256\nfrom the installation's private `secrets/application.secret`. Independent\ninstalls of the same app get distinct identities and share the global built-in\nrole IDs. Restarts reuse the same installation identities. Treat\n`secrets/application.secret` as installation identity material. Preserve it\nwith the database in protected backups and during a restore or move of the\nsame installation. Deleting, replacing, or rotating the file changes the\nderived identities and invalidates existing bootstrap and local MCP tokens.\nKeep only one active installation for a given secret. A copied data root\nretains the same owner, tenant, and writer IDs, and the runtime currently has\nno way to detect that copy. Start independent installations with a fresh data\nroot.\n\nThe invitation and active owner membership authorize the owner. The same\nseed derives a separate writer UUID. The local runtime binds it to the\ndatabase so local edits to tracked rows identify this installation, including\nafter a restart.\n\nA tenant with the selected slug causes `owner_tenant_conflict`, unless\n`adoptExisting: true` explicitly allows adopting an active root tenant with\nzero memberships of any status. An adopted tenant keeps its id and name.\nThe claim rejects an unrelated row using the reserved tenant UUID and an\naccount using the owner UUID or the claimed email. The owner Person and\nMembership also use strict inserts. A failed claim rolls back\nand leaves the invitation claimable.\nAuthenticated session TTLs are configured in whole seconds with a minimum of\none second, and invalid values are rejected before filesystem mutation.\n\nBackground jobs and application-defined paid capabilities remain disabled until\nexplicitly enabled. With embedded job topology, `createEmbeddedJobRunner()`\nreturns the normal s-m-r-t `TaskRunner`, so the application keeps one enqueue\nand execution contract without requiring a separate worker service.\n\n### Local desktop MCP credentials\n\nA desktop product can mount this opt-in SvelteKit route in\n`src/routes/api/local/mcp-credentials/+server.ts`.\n\n```ts\nimport { createLocalMcpCredentialHandler } from '@happyvertical/smrt-app-runtime/sveltekit';\nimport { runtime } from '$lib/server/smrt';\n\nexport const POST = createLocalMcpCredentialHandler(runtime);\n```\n\nServe it on the desktop app's direct loopback listener, with the adapter's\nsocket peer address and request Host. Keep forwarded address, host, and\nprotocol configuration unset. Set adapter-node's\n`ORIGIN` to the actual desktop URL, such as `http://127.0.0.1:4678`, because\nadapter-node otherwise assumes HTTPS. The handler checks the socket peer,\nloopback URL host, matching Host and exact Origin. When the browser\nsends `Sec-Fetch-Site`, it must be `same-origin`. Call the endpoint from the\napp's signed-in, same-origin desktop webview using its normal session cookie.\nThe session must use local bootstrap, password or passkey authentication,\nhave a direct active membership, and match the sole active system Owner\nmembership of the installation. Device, layered and permission-capped sessions\nare rejected.\n\nSend a JSON POST with an opaque lowercase UUIDv4 `id` generated by the\ndesktop app, a nonempty array of permission slugs, and an explicit integer\nlifetime from 60 to 31,536,000 seconds.\n\n```json\n{\n \"action\": \"put\",\n \"id\": \"2c70f518-b95a-445b-a75e-8126ae7c032a\",\n \"scopes\": [\"notes.read\"],\n \"expiresInSeconds\": 3600\n}\n```\n\nThe first PUT creates the slot. A later POST with the same `action: \"put\"`\nreplaces its bearer, scopes and expiry atomically. Scopes must be held by\nboth the live owner and the authenticated session. Successful replies carry\n`{ id, token, scopes, expiresAt }` in a private `Cache-Control: no-store`\nresponse. Pass the bearer directly to native credential handling for the MCP\nbridge. Keep it out of arguments, URLs, logs, browser storage, and generated\nplugin artifacts. The installation secret stays on the server, and the\ndatabase stores only the bearer HMAC.\n\nPersist the non-secret slot ID before the first request and serialize actions\nper slot. A failed write preserves the previous credential. If the outcome is\nunknown or the response is lost, repeat PUT with the same ID. Each successful\nwrite produces a fresh credential, and only the latest committed write\nverifies. A retry therefore recovers the slot without leaving an extra active\ncredential.\n\nSend `{ \"action\": \"revoke\", \"id\": \"<slot-id>\" }` with an unrestricted owner\nsession to disconnect. Repeating it returns `{ \"status\": \"revoked\" }`.\nRevocation is permanent for the slot even if a PUT was delayed or no PUT\ncompleted, so use a new UUID to reconnect. The operator CLI can list or\nrevoke the stored UUIDv8 record. Desktop slots and manual CLI token IDs are\nseparate namespaces.\n\nFailures carry a fixed `code` and no credentials. A revoked slot returns\n`409 slot_unavailable`; validation failures return `400 invalid_request`\nor `400 invalid_scope`; requests failing the trust checks return\n`403 forbidden`. Deployed profiles return `404 unavailable` to requests\nthat reach the profile check.\n\n## SvelteKit entry\n\n`@happyvertical/smrt-app-runtime/sveltekit` composes the profile runtime,\ntenant selection, the signed session, and authorized-tenant locals so an app's\nserver hooks are a few lines. `@sveltejs/kit` is an optional peer dependency;\nthe root entry never imports it.\n\n```ts\n// src/hooks.server.ts\nimport { createSmrtSvelteKitRuntime } from '@happyvertical/smrt-app-runtime/sveltekit';\n\nexport const runtime = createSmrtSvelteKitRuntime({\n prepareDatabase: runApplicationMigrations, // optional, idempotent\n});\nexport const { handle, init } = runtime;\n```\n\n```ts\n// src/app.d.ts\nimport type { SmrtRuntimeLocals } from '@happyvertical/smrt-app-runtime/sveltekit';\ndeclare global {\n namespace App {\n interface Locals extends SmrtRuntimeLocals {}\n }\n}\n```\n\n`init` is the fail-closed startup gate (local runtime or deployed bindings) and\n`handle` waits for it, then runs, in order: URL tenant selection into\n`locals.selectedTenant*` (never tenant context; tenant headers are ignored),\nthe signed session, and publication of `locals.tenantContext` only when the\nactive context matches the verified session tenant. A session's tenant is\naccepted only for an active direct membership or a legitimately inherited\none (`isSessionTenantAuthorized()`, the same rule deployed `restoreSession()`\napplies); otherwise the request is unauthenticated (no user, permissions,\ntenant, or tenant context). `database-rls` isolation always runs the request\nRLS transaction (`session.postgresRls` cannot disable it); a\n`session.skipPaths` prefix skips session loading only and still runs inside\nthat transaction as an anonymous principal. During such a request\n`runtime.classOptions()` / `runtime.databaseConfig()` return the\ntransaction-bound request database (unless the class has its own `db`\noverride), so call them per request and never retain the result. Downstream code runs\nat most once: its error propagates unchanged, and a session-layer failure\nbefore it returns 500 with no authenticated locals. Defaults read `SMRT_APP_ID`, `SMRT_DATA_DIR`, `HOST`, `DATABASE_URL`,\n`TENANT_BASE_DOMAIN`, and `SMRT_BACKGROUND_JOBS`; `smrt.config` `runtime`\nselects the profile (local when absent; a present `null`/`false`/`0`/`''`\nfails closed, as in `smrt app`). Deployed profiles additionally require\n`providerReadiness` probes and fail closed without them. In the local profile\nthe runtime holds the single-writer lease shared with `smrt app` operations by\ndefault (`defaultWriterLease()`: `writer.lease` under\n`prepareApplicationStateRoot()`, presenting `SMRT_OPERATION_INSTANCE`); pass\n`acquireWriterLease` to replace it or `acquireWriterLease: false` to hold none.\nOptional hooks: `onBootstrapInvitation` (present a newly issued setup token),\n`selectTenant`, `session`, and `classOverrides`.\n`runtime.getCollection(className)` returns the registered collection built\nfrom `runtime.classOptions(className)` on that call. Each generated `/api/*`\nroute embeds a prelude that imports the app's `src/lib/server/smrt.ts` and\nresolves collections through its exported `runtime` (core's\n`createGeneratedCollectionAccess()`), so an app's `smrt.ts` is only the\nruntime and its options. A legacy `getCollection`/`getSmrtConfig` export there\nstill takes precedence for one release, with a deprecation warning.\n\nMountable routes:\n\n```ts\n// src/routes/api/_runtime/health/+server.ts\nexport const GET = createRuntimeHealthHandler(runtime);\n// src/routes/api/_runtime/diagnostics/+server.ts\nexport const GET = createRuntimeDiagnosticsHandler({ runtime, toolNames });\n// src/routes/setup/+page.server.ts\nexport const { load, actions } = createOwnerSetupPage(runtime);\n// src/routes/+layout.server.ts\nexport const load = createSessionLayoutLoad();\n// src/routes/sign-out/+server.ts\nexport const POST = createSignOutHandler(runtime);\n```\n\n`createSessionLayoutLoad()` returns `session.user` (`{ id, email, name? }` or\n`null`). `createSignOutHandler` accepts only same-origin `POST` (405 / 403\notherwise), destroys the session, clears the cookie, and answers 204 (303 to\n`/` for HTML form posts).\n\nDiagnostics authorize (owner role or `runtime_diagnostics.read` on an active,\nsession-matching membership) before reading the runtime and return only stable\n`{ schemaVersion: 1, error: { code } }` failures. Owner setup is local-only and\nre-checks on every request that both the peer address and the URL host are\nloopback; its `default` action reads `token`, `name`, `email`, and optional `password` and\n`tenantName`, sets the session cookie, and redirects 303, or returns\n`fail(status, { code, message })` with `setup_disabled` (404),\n`setup_unavailable` (403), `setup_invalid_input` (400), `setup_invalid`\n(400), or `setup_conflict` (409, the `ownerTenant` slug already exists). Claim error text is never returned. After the claim commits, the\nruntime removes the `smrt app setup` / `recover` hand-off files\n(`ONBOARDING_HANDOFF_FILES`: `onboarding.json`, `onboarding-launch.html`) from\n`runtime.applicationStateRoot()`, so `pnpm app:open` stops offering the spent\ninvitation; failed claims leave them untouched. `onOwnerClaimed` remains for\nextra app cleanup and `removeOnboardingHandoff: false` opts out.\n\nPassword-based applications should pass `{ requirePassword: true }` to\n`createOwnerSetupPage` and include a password field in their setup form. The\npassword is stored in the owner-claim transaction; a rejected password leaves\nthe token available for retry. Existing bootstrap consumers may omit it.\nUse one server-only `PasswordPolicyOptions` value for bootstrap and sign-in,\nincluding any pepper, length constraints, or custom rejection hook:\n\n```typescript\nconst passwordPolicy = { pepper: serverSecrets.passwordPepper, minLength: 16 };\nconst runtime = createSmrtSvelteKitRuntime({ passwordPolicy });\nconst passwords = await PasswordCredentialService.create({\n ...runtime.classOptions('User'),\n password: passwordPolicy,\n});\n```\n\n`initializeLocalApplicationRuntime` accepts the same `passwordPolicy` option.\nIt is never taken from form data or included in diagnostics. Bootstrap uses the\npolicy on its transaction-bound service, and registers password-management\npermissions before seeding the owner's grants.\n\n`resolveApplicationId()` and `runtimeConfigurationFingerprint()` (root entry)\nare the canonical app ID and secret-free configuration fingerprint shared by\nthe web health route and process managers.\n\nThe root entry also owns the operator state shared with `smrt app`:\n`prepareApplicationStateRoot()` (private, app-bound state directory),\n`withOperationLock()`, `acquireWriterLease()` / `readActiveWriterLease()`,\nand `createProviderReadinessProbe()`. The SvelteKit runtime takes the writer\nlease itself; a SvelteKit app passes only the readiness probe and its own\noptions, without depending on the CLI:\n\n```ts\n// src/lib/server/smrt.ts\nimport { createProviderReadinessProbe } from '@happyvertical/smrt-app-runtime';\nimport { createSmrtSvelteKitRuntime } from '@happyvertical/smrt-app-runtime/sveltekit';\n\nexport const runtime = createSmrtSvelteKitRuntime({\n providerReadiness: createProviderReadinessProbe,\n});\n```\n\n## Self-hosted and cloud applications\n\nThe deployed initializer validates the selected profile against concrete,\nprovider-owned bindings. Database URLs, OIDC credentials, storage keys, and\nsecret-manager identifiers stay inside those adapters and are never copied into\nthe runtime snapshot.\n\n```ts\nimport {\n initializeDeployedApplicationRuntime,\n} from '@happyvertical/smrt-app-runtime';\nimport { getDatabase } from '@happyvertical/sql';\n\nconst initialized = await initializeDeployedApplicationRuntime({\n profile: 'self-hosted',\n database: {\n engine: 'postgres',\n connect: () => getDatabase({\n type: 'postgres',\n url: requirePrivateSetting('DATABASE_URL'),\n }),\n close: async (db) => {\n await db.close?.();\n },\n },\n authentication: {\n provider: 'oidc',\n readiness: () => oidcProvider.assertReady(),\n },\n assets: {\n provider: 's3-compatible',\n readiness: () => assetProvider.assertReady(),\n },\n secrets: {\n provider: 'environment',\n readiness: () => secretProvider.assertReady(),\n },\n prepareDatabase: runApplicationMigrations,\n});\n```\n\nStartup validates every binding, including the provider-owned database cleanup\nboundary, before opening a connection. A missing public-auth or secret binding,\na selector mismatch, an unavailable provider, a failed\nPostgreSQL probe, or a failed migration rejects startup. Provider failures are\nreported with stable component codes and omit the underlying provider message\nso credentials cannot leak into HTTP or orchestration payloads.\nA custom database readiness callback is additive; the runtime always runs its\nown PostgreSQL-specific server-version probe before startup or readiness can\nsucceed.\nIf cleanup after a startup failure also fails,\n`DeployedRuntimeCleanupError.retryCleanup()` retains the redacted, idempotent\nownership path until the provider closes successfully.\n\n`health()` reports process liveness. `readiness()` rechecks PostgreSQL,\nauthentication, assets, and secrets and returns only `ready` / `not-ready`\ncomponent states. `diagnostics()` reports the resolved profile, explicit\nprovider selectors, tenancy posture, and worker topology without secret values.\n\nJob producers keep using `SmrtObject.bg()` / `background().enqueue()` in every\nprofile. Deployed worker entry points initialize the ordinary runners against\nthe same PostgreSQL database. A worker is a separate Node process, so it must\nfirst import the application's compiled object registration —\n`.smrt/runtime/register.js`, produced by a SvelteKit `smrtPlugin()` build — or\nany job targeting an application object fails with `Unknown object type`:\n\n```ts\nawait import('./.smrt/runtime/register.js');\n\nconst taskWorker = await initialized.createTaskWorker({ concurrency: 8 });\nawait taskWorker.start();\n\n// Run this in a separate schedule-worker process, not beside the web server.\nconst scheduleWorker = await initialized.createScheduleWorker();\nawait scheduleWorker.start();\n```\n\n`initialized.close()` drains in-flight readiness/session/worker initialization\nand serialized runner start/stop operations, stops every runner returned by\nthis runtime, and only then closes PostgreSQL.\nCallers may stop a runner earlier, but must not restart a returned runner after\nits runtime has begun shutdown; lifecycle-gated `start()` calls then reject.\nAn awaited `close()` request made from inside a tracked provider or worker\noperation acknowledges shutdown so that operation can unwind; callers outside\nthe operation continue to await complete worker and database cleanup.\n\nSelf-hosted deployments may select OIDC, magic-link, or password authentication, explicit\nsingle- or multi-tenancy, local or S3-compatible assets, and environment,\nlocal-file, or external secrets. Cloud requires hosted identity, PostgreSQL,\nmulti-tenancy with required tenant context, managed/external secrets,\nmanaged/S3-compatible object storage, public TLS, and scalable workers. Cloud\nmay select application isolation instead of PostgreSQL RLS, but it can never\nenable an unscoped/root-tenant fallback.\n\nThe application runtime does not provision databases, buckets, identity\nproviders, secret managers, worker fleets, TLS, billing, or a hosted control\nplane. Those remain operator/managed-platform responsibilities. Enabling the\n`database-rls` selector also requires the deployment migration to apply the\ndocumented s-m-r-t PostgreSQL policies; the selector does not grant or mutate\ndatabase privileges at runtime.\n"
|
|
80
|
+
"content": "# @happyvertical/smrt-app-runtime\n\nReusable infrastructure composition for s-m-r-t applications. The package\nimplements the validated profiles from `@happyvertical/smrt-config` without\nforking domain objects, generated surfaces, effects, approvals, or job calls.\n\n## Private local applications\n\n```ts\nimport {\n initializeLocalApplicationRuntime,\n} from '@happyvertical/smrt-app-runtime';\n\nconst { runtime, bootstrap, diagnostics } =\n await initializeLocalApplicationRuntime({\n appId: 'my-app',\n sourceRoot: process.cwd(),\n prepareDatabase: runApplicationMigrations,\n });\n\n// Show bootstrap.token only in the loopback onboarding URL. The plaintext is\n// returned once and only its HMAC is persisted.\n```\n\nApplication migrations run through the explicit `prepareDatabase` hook while\nthe runtime holds the root lease. A rejected migration is normalized into a\n`LocalRuntimeError` with the stable `migration_failed` code and the fixed,\nsecret-free message exported as `MIGRATION_FAILED_MESSAGE`: \"The application\nmigration step failed; run pnpm app:setup and inspect the private migration\nlogs.\" The migration driver's own text — a likely carrier of a connection\nstring, credential, path, or environment value — is never surfaced; it is kept\nas a non-enumerable `cause` for private logs. Startup releases the database\nhandle and the initialization lease, so the same application root and data\ndirectory remain retryable once the migration is fixed.\n\nThe path resolver selects the current user's OS application-data directory\n(`~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on Windows, or\n`$XDG_DATA_HOME` / `~/.local/share` on Linux). Secure initialization currently\nrequires Node to expose nonzero `O_NOFOLLOW` and `O_DIRECTORY` filesystem\nflags; it fails closed on platforms without that custody support and therefore\ndoes not yet claim Windows runtime support. The data root contains a mode-0600\nSQLite database, a user-owned asset directory, and generated mode-0600\napplication secret material. The root must be a dedicated application\ndirectory: placing it inside the source checkout or choosing an ancestor is\nrefused, as is choosing the user home itself or the filesystem root. An\nexplicitly configured root that already exists must already be\nowned by the current user with mode `0700`; initialization rejects it without\nchanging permissions or creating artifacts when that custody proof fails.\nAn empty root is claimed with an app-specific, empty mode-0600 marker. A\npopulated root is accepted only with that valid marker, so selecting an\nunrelated private directory fails without changing its contents. A pending\nmarker makes the claim crash-recoverable while the database is first acquired;\nit is promoted atomically after the released SQL custody boundary verifies the\ncomplete ancestor/root chain, including macOS ACLs. Failed custody removes the\npending claim and every directory only when they were created by that attempt;\nan inherited pending claim and its database remain authoritative for retry.\nInitializers for the same data root are serialized across processes by an\nexclusive transaction in a dedicated SQLite lock database under a private,\nroot-keyed custody directory kept beside the data it protects:\n`<parent of the data root>/.smrt-lease-<key>/initialization.sqlite`. That\nparent has already passed the custody preflight and is the one location every\nprocess sharing the data directory sees, so the lease also works inside a user\nnamespace (agent sandboxes, rootless containers) where `/tmp` reads as owned by\nthe overflow uid. The historical per-user `/tmp/.smrt-<uid>/<key>` lease is\nstill taken afterwards, so a process running an older release (which takes\nonly that lease) stays excluded. When `/tmp` fails the ancestry owner check\nbecause its owner is unmapped, the legacy lease is taken lock-only: the lock\nfile is never written, every ancestor must still be a real directory that is\nnot group/world writable unless sticky, and the lock directories and file must\nbe current-user-owned and private. A held legacy lease therefore makes the new\nprocess wait (and fail closed after two minutes). Inside a user namespace that\nremaps the uid (for example `unshare -Ur`, where host uid 1000 reads as 0), the\nlegacy lease is taken for both the in-namespace uid (`/tmp/.smrt-0`, what an\nolder release inside the namespace locks) and, in that order, the uid's mapping\nin `/proc/self/uid_map` (`/tmp/.smrt-<host uid>`, what an older host process\nlocks); identical paths are taken once, so a host process takes the single\nlease it always did. Every new process takes the beside-data lease first, which\nis what keeps the fixed legacy order deadlock-free. When the in-namespace uid\ndiffers from its mapping and its `/tmp/.smrt-<uid>` root already exists as a\nreal directory owned by someone else (under `unshare -Ur`, host root's\n`/tmp/.smrt-0` reads as the overflow uid), that one candidate is skipped: no\nprocess in the namespace can hold a lease beneath a root it does not own. A\nsymlink there, and any problem with the mapped candidate, still fails closed.\nA map that\ncannot be read, parsed, or does not cover the current uid fails closed. Only\none namespace level is visible, so a doubly nested remap is not translated\npast its parent. The residual rule: when the\nlegacy lock directory cannot be created at all (`/tmp` not writable or\nread-only in that view) and no legacy lock file exists, the legacy lease is\nskipped. When the data root's parent is not writable by the current user (or is\nread-only) the secure `/tmp` lease is required instead, as before. The\nreleased `@happyvertical/sql`\ntrusted-parent boundary validates each lease directory, its ancestors, and the lock\nleaf for ownership, write permissions, static links, and macOS ACLs before it\nopens the lock database. Atomic SQLite locking elects one owner without deleting\nor replacing a pathname, so there is no stale-file ABA window and no PID or\nclock lease. Process or worker death releases the kernel lock automatically. A\nread-only path, custody, and ownership-marker preflight rejects an obviously\ninvalid application root before creating its lock-registry entry; the complete\nchecks run again under the lease before application-root mutation. The lease\ncovers storage acquisition, secret publication, SQLite tuning,\napplication migrations, and bootstrap construction. Contenders wait up to two\nminutes for that complete sequence, so normal migrations can finish without\noverlap. Marker or temporary-secret cleanup therefore cannot race another\nactive initializer.\nEvery existing path component is opened without following symbolic links and\nchecked against its canonical path before descendants or secret bytes are\nwritten. The runtime then acquires SQLite through `@happyvertical/sql`'s\nexplicit `node:sqlite` trusted-parent custody boundary, rooted at the mode-0700\napplication data directory. Unsupported runtimes or platforms and unsafe\nownership, permissions, ACLs, or path components fail closed. The local\n`runtime.databaseConfig()` and default `runtime.classOptions()` retain that\nsame driver, custody, and root configuration when application collections open\nanother connection; an explicit per-class database override still wins.\n\nThe application secret is published by atomically linking a fully written,\nsynced mode-0600 temporary file into place. Concurrent installers validate and\nreuse the one winning value; an incomplete or malformed existing secret is\nrejected rather than overwritten, and stale interrupted temporary files are\nremoved after a complete value is durably available.\n\nThe custody boundary prevents static link traversal and mutation by other OS\nprincipals while the application retains control of that directory. Hostile\ncode already running as the same user is outside this boundary; isolating\nsame-account processes requires an OS sandbox and a descriptor-relative SQLite\nVFS.\n\nOwner onboarding binds to `127.0.0.1` by default and accepts only loopback IP\nliterals (`127.0.0.0/8` or `::1`), avoiding hostname-resolution ambiguity.\nThe first valid claim creates a tenant-bound `Person`, `User`, default `Tenant`,\nowner `Role` / `Membership`, and server-side `Session` atomically. The owner\nPerson belongs to the actual created or adopted tenant; its shared profile type\nremains global. Startup and setup are idempotent; replayed, expired, or\nconcurrent claims fail closed.\n`ownerTenant: { slug, name, adoptExisting }` (also accepted by the SvelteKit\nruntime) selects the bootstrap tenant; the default is a new tenant with slug\n`default`. The default tenant, owner User, owner Person, and owner Membership\nhave separate UUIDv8 identifiers derived with domain-separated HMAC-SHA256\nfrom the installation's private `secrets/application.secret`. Independent\ninstalls of the same app get distinct identities and share the global built-in\nrole IDs. Restarts reuse the same installation identities. Treat\n`secrets/application.secret` as installation identity material. Preserve it\nwith the database in protected backups and during a restore or move of the\nsame installation. Deleting, replacing, or rotating the file changes the\nderived identities and invalidates existing bootstrap and local MCP tokens.\nKeep only one active installation for a given secret. A copied data root\nretains the same owner, tenant, and writer IDs, and the runtime currently has\nno way to detect that copy. Start independent installations with a fresh data\nroot.\n\nThe invitation and active owner membership authorize the owner. The same\nseed derives a separate writer UUID. The local runtime binds it to the\ndatabase so local edits to tracked rows identify this installation, including\nafter a restart.\n\nA tenant with the selected slug causes `owner_tenant_conflict`, unless\n`adoptExisting: true` explicitly allows adopting an active root tenant with\nzero memberships of any status. An adopted tenant keeps its id and name.\nThe claim rejects an unrelated row using the reserved tenant UUID and an\naccount using the owner UUID or the claimed email. The owner Person and\nMembership also use strict inserts. A failed claim rolls back\nand leaves the invitation claimable.\nAuthenticated session TTLs are configured in whole seconds with a minimum of\none second, and invalid values are rejected before filesystem mutation.\n\nBackground jobs and application-defined paid capabilities remain disabled until\nexplicitly enabled. With embedded job topology, `createEmbeddedJobRunner()`\nreturns the normal s-m-r-t `TaskRunner`, so the application keeps one enqueue\nand execution contract without requiring a separate worker service.\n\n### Local desktop MCP credentials\n\nA desktop product can mount this opt-in SvelteKit route in\n`src/routes/api/local/mcp-credentials/+server.ts`.\n\n```ts\nimport { createLocalMcpCredentialHandler } from '@happyvertical/smrt-app-runtime/sveltekit';\nimport { runtime } from '$lib/server/smrt';\n\nexport const POST = createLocalMcpCredentialHandler(runtime);\n```\n\nServe it on the desktop app's direct loopback listener, with the adapter's\nsocket peer address and request Host. Keep forwarded address, host, and\nprotocol configuration unset. Set adapter-node's\n`ORIGIN` to the actual desktop URL, such as `http://127.0.0.1:4678`, because\nadapter-node otherwise assumes HTTPS. The handler checks the socket peer,\nloopback URL host, matching Host and exact Origin. When the browser\nsends `Sec-Fetch-Site`, it must be `same-origin`. Call the endpoint from the\napp's signed-in, same-origin desktop webview using its normal session cookie.\nThe session must use local bootstrap, password or passkey authentication,\nhave a direct active membership, and match the sole active system Owner\nmembership of the installation. Device, layered and permission-capped sessions\nare rejected.\n\nSend a JSON POST with an opaque lowercase UUIDv4 `id` generated by the\ndesktop app, a nonempty array of permission slugs, and an explicit integer\nlifetime from 60 to 31,536,000 seconds.\n\n```json\n{\n \"action\": \"put\",\n \"id\": \"2c70f518-b95a-445b-a75e-8126ae7c032a\",\n \"scopes\": [\"notes.read\"],\n \"expiresInSeconds\": 3600\n}\n```\n\nThe first PUT creates the slot. A later POST with the same `action: \"put\"`\nreplaces its bearer, scopes and expiry atomically. Scopes must be held by\nboth the live owner and the authenticated session. Successful replies carry\n`{ id, token, scopes, expiresAt }` in a private `Cache-Control: no-store`\nresponse. Pass the bearer directly to native credential handling for the MCP\nbridge. Keep it out of arguments, URLs, logs, browser storage, and generated\nplugin artifacts. The installation secret stays on the server, and the\ndatabase stores only the bearer HMAC.\n\nPersist the non-secret slot ID before the first request and serialize actions\nper slot. A failed write preserves the previous credential. If the outcome is\nunknown or the response is lost, repeat PUT with the same ID. Each successful\nwrite produces a fresh credential, and only the latest committed write\nverifies. A retry therefore recovers the slot without leaving an extra active\ncredential.\n\nSend `{ \"action\": \"revoke\", \"id\": \"<slot-id>\" }` with an unrestricted owner\nsession to disconnect. Repeating it returns `{ \"status\": \"revoked\" }`.\nRevocation is permanent for the slot even if a PUT was delayed or no PUT\ncompleted, so use a new UUID to reconnect. The operator CLI can list or\nrevoke the stored UUIDv8 record. Desktop slots and manual CLI token IDs are\nseparate namespaces.\n\nFailures carry a fixed `code` and no credentials. A revoked slot returns\n`409 slot_unavailable`; validation failures return `400 invalid_request`\nor `400 invalid_scope`; requests failing the trust checks return\n`403 forbidden`. Deployed profiles return `404 unavailable` to requests\nthat reach the profile check.\n\n## SvelteKit entry\n\n`@happyvertical/smrt-app-runtime/sveltekit` composes the profile runtime,\ntenant selection, the signed session, and authorized-tenant locals so an app's\nserver hooks are a few lines. `@sveltejs/kit` is an optional peer dependency;\nthe root entry never imports it.\n\n```ts\n// src/hooks.server.ts\nimport { createSmrtSvelteKitRuntime } from '@happyvertical/smrt-app-runtime/sveltekit';\n\nexport const runtime = createSmrtSvelteKitRuntime({\n prepareDatabase: runApplicationMigrations, // optional, idempotent\n});\nexport const { handle, init } = runtime;\n```\n\n```ts\n// src/app.d.ts\nimport type { SmrtRuntimeLocals } from '@happyvertical/smrt-app-runtime/sveltekit';\ndeclare global {\n namespace App {\n interface Locals extends SmrtRuntimeLocals {}\n }\n}\n```\n\n`init` is the fail-closed startup gate (local runtime or deployed bindings) and\n`handle` waits for it, then runs, in order: URL tenant selection into\n`locals.selectedTenant*` (never tenant context; tenant headers are ignored),\nthe signed session, and publication of `locals.tenantContext` only when the\nactive context matches the verified session tenant. A session's tenant is\naccepted only for an active direct membership or a legitimately inherited\none (`isSessionTenantAuthorized()`, the same rule deployed `restoreSession()`\napplies); otherwise the request is unauthenticated (no user, permissions,\ntenant, or tenant context). `database-rls` isolation always runs the request\nRLS transaction (`session.postgresRls` cannot disable it); a\n`session.skipPaths` prefix skips session loading only and still runs inside\nthat transaction as an anonymous principal. During such a request\n`runtime.classOptions()` / `runtime.databaseConfig()` return the\ntransaction-bound request database (unless the class has its own `db`\noverride), so call them per request and never retain the result. Downstream code runs\nat most once: its error propagates unchanged, and a session-layer failure\nbefore it returns 500 with no authenticated locals. Defaults read `SMRT_APP_ID`, `SMRT_DATA_DIR`, `HOST`, `DATABASE_URL`,\n`TENANT_BASE_DOMAIN`, and `SMRT_BACKGROUND_JOBS`; `smrt.config` `runtime`\nselects the profile (local when absent; a present `null`/`false`/`0`/`''`\nfails closed, as in `smrt app`). Deployed profiles additionally require\n`providerReadiness` probes and fail closed without them. In the local profile\nthe runtime holds the single-writer lease shared with `smrt app` operations by\ndefault (`defaultWriterLease()`: `writer.lease` under\n`prepareApplicationStateRoot()`, presenting `SMRT_OPERATION_INSTANCE`); pass\n`acquireWriterLease` to replace it or `acquireWriterLease: false` to hold none.\nOptional hooks: `onBootstrapInvitation` (present a newly issued setup token),\n`selectTenant`, `session`, and `classOverrides`.\n`runtime.getCollection(className)` returns the registered collection built\nfrom `runtime.classOptions(className)` on that call. Each generated `/api/*`\nroute embeds a prelude that imports the app's `src/lib/server/smrt.ts` and\nresolves collections through its exported `runtime` (core's\n`createGeneratedCollectionAccess()`), so an app's `smrt.ts` is only the\nruntime and its options. A legacy `getCollection`/`getSmrtConfig` export there\nstill takes precedence for one release, with a deprecation warning.\n\nMountable routes:\n\n```ts\n// src/routes/api/_runtime/health/+server.ts\nexport const GET = createRuntimeHealthHandler(runtime);\n// src/routes/api/_runtime/diagnostics/+server.ts\nexport const GET = createRuntimeDiagnosticsHandler({ runtime, toolNames });\n// src/routes/setup/+page.server.ts\nexport const { load, actions } = createOwnerSetupPage(runtime);\n// src/routes/+layout.server.ts\nexport const load = createSessionLayoutLoad();\n// src/routes/sign-out/+server.ts\nexport const POST = createSignOutHandler(runtime);\n```\n\n`createSessionLayoutLoad()` returns `session.user` (`{ id, email, name? }` or\n`null`). `createSignOutHandler` accepts only same-origin `POST` (405 / 403\notherwise), destroys the session, clears the cookie, and answers 204 (303 to\n`/` for HTML form posts).\n\nDiagnostics authorize (owner role or `runtime_diagnostics.read` on an active,\nsession-matching membership) before reading the runtime and return only stable\n`{ schemaVersion: 1, error: { code } }` failures. Owner setup is local-only and\nre-checks on every request that both the peer address and the URL host are\nloopback; its `default` action reads `token`, `name`, `email`, and optional `password` and\n`tenantName`, sets the session cookie, and redirects 303, or returns\n`fail(status, { code, message })` with `setup_disabled` (404),\n`setup_unavailable` (403), `setup_invalid_input` (400), `setup_invalid`\n(400), or `setup_conflict` (409, the `ownerTenant` slug already exists). Claim error text is never returned. After the claim commits, the\nruntime removes the `smrt app setup` / `recover` hand-off files\n(`ONBOARDING_HANDOFF_FILES`: `onboarding.json`, `onboarding-launch.html`) from\n`runtime.applicationStateRoot()`, so `pnpm app:open` stops offering the spent\ninvitation; failed claims leave them untouched. `onOwnerClaimed` remains for\nextra app cleanup and `removeOnboardingHandoff: false` opts out.\n\nPassword-based applications should pass `{ requirePassword: true }` to\n`createOwnerSetupPage` and include a password field in their setup form. The\npassword is stored in the owner-claim transaction; a rejected password leaves\nthe token available for retry. Existing bootstrap consumers may omit it.\nUse one server-only `PasswordPolicyOptions` value for bootstrap and sign-in,\nincluding any pepper, length constraints, or custom rejection hook:\n\n```typescript\nconst passwordPolicy = { pepper: serverSecrets.passwordPepper, minLength: 16 };\nconst runtime = createSmrtSvelteKitRuntime({ passwordPolicy });\nconst passwords = await PasswordCredentialService.create({\n ...runtime.classOptions('User'),\n password: passwordPolicy,\n});\n```\n\n`initializeLocalApplicationRuntime` accepts the same `passwordPolicy` option.\nIt is never taken from form data or included in diagnostics. Bootstrap uses the\npolicy on its transaction-bound service, and registers password-management\npermissions before seeding the owner's grants.\n\n`resolveApplicationId()` and `runtimeConfigurationFingerprint()` (root entry)\nare the canonical app ID and secret-free configuration fingerprint shared by\nthe web health route and process managers.\n\nThe root entry also owns the operator state shared with `smrt app`:\n`prepareApplicationStateRoot()` (private, app-bound state directory),\n`withOperationLock()`, `acquireWriterLease()` / `readActiveWriterLease()`,\nand `createProviderReadinessProbe()`. The SvelteKit runtime takes the writer\nlease itself; a SvelteKit app passes only the readiness probe and its own\noptions, without depending on the CLI:\n\n```ts\n// src/lib/server/smrt.ts\nimport { createProviderReadinessProbe } from '@happyvertical/smrt-app-runtime';\nimport { createSmrtSvelteKitRuntime } from '@happyvertical/smrt-app-runtime/sveltekit';\n\nexport const runtime = createSmrtSvelteKitRuntime({\n providerReadiness: createProviderReadinessProbe,\n});\n```\n\n## Self-hosted and cloud applications\n\nThe deployed initializer validates the selected profile against concrete,\nprovider-owned bindings. Database URLs, OIDC credentials, storage keys, and\nsecret-manager identifiers stay inside those adapters and are never copied into\nthe runtime snapshot.\n\n```ts\nimport {\n initializeDeployedApplicationRuntime,\n} from '@happyvertical/smrt-app-runtime';\nimport { getDatabase } from '@happyvertical/sql';\n\nconst initialized = await initializeDeployedApplicationRuntime({\n profile: 'self-hosted',\n database: {\n engine: 'postgres',\n connect: () => getDatabase({\n type: 'postgres',\n url: requirePrivateSetting('DATABASE_URL'),\n }),\n close: async (db) => {\n await db.close?.();\n },\n },\n authentication: {\n provider: 'oidc',\n readiness: () => oidcProvider.assertReady(),\n },\n assets: {\n provider: 's3-compatible',\n readiness: () => assetProvider.assertReady(),\n },\n secrets: {\n provider: 'environment',\n readiness: () => secretProvider.assertReady(),\n },\n prepareDatabase: runApplicationMigrations,\n});\n```\n\nStartup validates every binding, including the provider-owned database cleanup\nboundary, before opening a connection. A missing public-auth or secret binding,\na selector mismatch, an unavailable provider, a failed\nPostgreSQL probe, or a failed migration rejects startup. Provider failures are\nreported with stable component codes and omit the underlying provider message\nso credentials cannot leak into HTTP or orchestration payloads.\nA custom database readiness callback is additive; the runtime always runs its\nown PostgreSQL-specific server-version probe before startup or readiness can\nsucceed.\nIf cleanup after a startup failure also fails,\n`DeployedRuntimeCleanupError.retryCleanup()` retains the redacted, idempotent\nownership path until the provider closes successfully.\n\n`health()` reports process liveness. `readiness()` rechecks PostgreSQL,\nauthentication, assets, and secrets and returns only `ready` / `not-ready`\ncomponent states. `diagnostics()` reports the resolved profile, explicit\nprovider selectors, tenancy posture, and worker topology without secret values.\n\nJob producers keep using `SmrtObject.bg()` / `background().enqueue()` in every\nprofile. Deployed worker entry points initialize the ordinary runners against\nthe same PostgreSQL database. A worker is a separate Node process, so it must\nfirst import the application's compiled object registration —\n`.smrt/runtime/register.js`, produced by a SvelteKit `smrtPlugin()` build — or\nany job targeting an application object fails with `Unknown object type`:\n\n```ts\nawait import('./.smrt/runtime/register.js');\n\nconst taskWorker = await initialized.createTaskWorker({ concurrency: 8 });\nawait taskWorker.start();\n\n// Run this in a separate schedule-worker process, not beside the web server.\nconst scheduleWorker = await initialized.createScheduleWorker();\nawait scheduleWorker.start();\n```\n\n`initialized.close()` drains in-flight readiness/session/worker initialization\nand serialized runner start/stop operations, stops every runner returned by\nthis runtime, and only then closes PostgreSQL.\nCallers may stop a runner earlier, but must not restart a returned runner after\nits runtime has begun shutdown; lifecycle-gated `start()` calls then reject.\nAn awaited `close()` request made from inside a tracked provider or worker\noperation acknowledges shutdown so that operation can unwind; callers outside\nthe operation continue to await complete worker and database cleanup.\n\nSelf-hosted deployments may select OIDC, magic-link, or password authentication, explicit\nsingle- or multi-tenancy, local or S3-compatible assets, and environment,\nlocal-file, or external secrets. Cloud requires hosted identity, PostgreSQL,\nmulti-tenancy with required tenant context, managed/external secrets,\nmanaged/S3-compatible object storage, public TLS, and scalable workers. Cloud\nmay select application isolation instead of PostgreSQL RLS, but it can never\nenable an unscoped/root-tenant fallback.\n\nThe application runtime does not provision databases, buckets, identity\nproviders, secret managers, worker fleets, TLS, billing, or a hosted control\nplane. Those remain operator/managed-platform responsibilities. Enabling the\n`database-rls` selector also requires the deployment migration to apply the\ndocumented s-m-r-t PostgreSQL policies; the selector does not grant or mutate\ndatabase privileges at runtime.\n\n## Vault recovery key wrapping\n\nThe Node-only `@happyvertical/smrt-app-runtime/vault-recovery` entry can wrap a\n32-byte vault data key with a recovery secret held by the user.\n`wrapVaultRecoveryKey({ keyId, key }, recoverySecret, { accountId, generation })`\nreturns an opaque record. `openVaultRecoveryKey(record, recoverySecret, context)`\nreturns the authenticated key ID and key. Use a high-entropy passphrase or a\nrandomly generated printable recovery code. Keep the secret separate from the\nwrapped record. Losing every authorized device and the recovery secret means\nlosing access to the data. These functions leave storage, device enrollment,\nrotation, and revocation to their callers.\n\nSupply the expected account and key generation from trusted local or pairing\nstate. The recovery record cannot establish which account or generation is\ncurrent. A holder of the wrapped record can try guesses offline, so callers\nshould run recovery locally after user action.\n\nThe v1 record starts with ASCII `HVRK`, the byte `01`, and a 16-byte random\nsalt. The rest is an uncompressed\n[SDK v1 envelope](https://github.com/happyvertical/sdk/blob/v0.111.6/packages/encryption/README.md#vault-snapshot-envelopes).\nIts plaintext is the 32-byte data key. The record has 195 bytes plus the\nUTF-8 byte length of the account ID. Readers reject incompatible lengths and\nenvelope headers before deriving the wrapping key. The header carries the data key's ID\nas 32 lowercase hex digits in the API. The authenticated context uses the\naccount ID, the fixed device slot `smrt-vault-recovery-v1`, and the unsigned\n64-bit generation as the sequence. The wrapping key is 32 bytes derived\nwith scrypt (`N=131072, r=8, p=1`) from the secret and the salt prefixed\nwith ASCII `smrt-vault-recovery-v1`. The secret is encoded as 16 to 1024 bytes\nof well-formed UTF-8 with no normalization or trimming.\n\n## Vault device key wrapping\n\nUse the Node entry `@happyvertical/smrt-app-runtime/vault-device-key` to wrap an\naccount data key on a device that already holds it. Pass a distinct, randomly\ngenerated 32-byte device secret for each device. Keep the device secret in local\ncredential custody and store or transfer only the returned wrapped record.\n\n`wrapVaultDeviceKey(dataKey, deviceSecret, context)` returns the record.\n`openVaultDeviceKey(record, deviceSecret, context)` returns the 32-byte data key\nafter authentication. Supply `{ accountId, deviceId, generation, keyId }` from\ntrusted device state. The generation is an unsigned 64-bit bigint and the key\nID is 32 lowercase hex digits. The caller manages enrollment, storage, and\nwhich generations are current. Removing a wrapped record cannot revoke a key\na device already recovered.\n\nA v1 record starts with ASCII `HVDK` and the byte `01`. The remainder is an\nuncompressed [SDK v1 envelope](https://github.com/happyvertical/sdk/blob/v0.111.6/packages/encryption/README.md#vault-snapshot-envelopes).\nIts plaintext is the 32-byte data key. Its authenticated context uses the\naccount ID, device ID, and generation as the snapshot sequence. Its header\ncarries the data key ID. Derive the 32-byte wrapping key from the device secret\nwith [HKDF-SHA256](https://www.rfc-editor.org/rfc/rfc5869), an empty salt, and\nASCII `smrt-vault-device-key-v1` as the info string.\n"
|
|
77
81
|
}
|
|
78
82
|
]
|
|
79
83
|
}
|
|
@@ -5,10 +5,10 @@ description: "Validated local, self-hosted, and cloud runtime composition for s-
|
|
|
5
5
|
status: "stable"
|
|
6
6
|
generated:
|
|
7
7
|
by: "smrt/scripts/generate-knowledge-okf.ts"
|
|
8
|
-
at: "2026-11-
|
|
8
|
+
at: "2026-11-07T00:32:27Z"
|
|
9
9
|
audience: "internal"
|
|
10
10
|
evidence_level: "manifest-described"
|
|
11
|
-
source_revision: "sha256:
|
|
11
|
+
source_revision: "sha256:15a5aac1608d813f06bc738f7840e745cb9cc248c47a5b89d05acd67eca10701"
|
|
12
12
|
sources:
|
|
13
13
|
- id: "manifest"
|
|
14
14
|
resource: "https://github.com/happyvertical/smrt/blob/main/packages/app-runtime/package.json"
|
|
@@ -47,6 +47,7 @@ sources:
|
|
|
47
47
|
|
|
48
48
|
## SDK dependencies
|
|
49
49
|
|
|
50
|
+
- `@happyvertical/encryption`
|
|
50
51
|
- `@happyvertical/sql`
|
|
51
52
|
|
|
52
53
|
## Exports
|
|
@@ -54,3 +55,5 @@ sources:
|
|
|
54
55
|
- `.`
|
|
55
56
|
- `./sveltekit`
|
|
56
57
|
- `./sveltekit-runtime`
|
|
58
|
+
- `./vault-device-key`
|
|
59
|
+
- `./vault-recovery`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface VaultDeviceKeyContext {
|
|
2
|
+
accountId: string;
|
|
3
|
+
deviceId: string;
|
|
4
|
+
generation: bigint;
|
|
5
|
+
keyId: string;
|
|
6
|
+
}
|
|
7
|
+
/** Wrap the account data key with an independently generated device secret. */
|
|
8
|
+
export declare function wrapVaultDeviceKey(dataKey: Uint8Array, deviceSecret: Uint8Array, { accountId, deviceId, generation, keyId }: VaultDeviceKeyContext): Uint8Array;
|
|
9
|
+
/** Open a device record against the caller's trusted key and device context. */
|
|
10
|
+
export declare function openVaultDeviceKey(record: Uint8Array, deviceSecret: Uint8Array, { accountId, deviceId, generation, keyId }: VaultDeviceKeyContext): Uint8Array;
|
|
11
|
+
//# sourceMappingURL=vault-device-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vault-device-key.d.ts","sourceRoot":"","sources":["../src/vault-device-key.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;AAcD,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,UAAU,EACnB,YAAY,EAAE,UAAU,EACxB,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,KAAK,EAAE,EAAE,qBAAqB,GAChE,UAAU,CAgBZ;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,YAAY,EAAE,UAAU,EACxB,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,KAAK,EAAE,EAAE,qBAAqB,GAChE,UAAU,CAkCZ"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { hkdfSync } from "node:crypto";
|
|
2
|
+
import { openVaultSnapshot, sealVaultSnapshot } from "@happyvertical/encryption/vault-snapshot";
|
|
3
|
+
//#region src/vault-device-key.ts
|
|
4
|
+
var RECORD_PREFIX = Buffer.of(72, 86, 68, 75, 1);
|
|
5
|
+
var ENVELOPE_PREFIX = Buffer.of(72, 86, 83, 69, 1, 1, 0);
|
|
6
|
+
var KEY_BYTES = 32;
|
|
7
|
+
var MAX_RECORD_BYTES = 672;
|
|
8
|
+
function deriveDeviceWrappingKey(deviceSecret) {
|
|
9
|
+
if (!(deviceSecret instanceof Uint8Array) || deviceSecret.length !== KEY_BYTES) throw new Error("Vault device secret must be 32 bytes");
|
|
10
|
+
return new Uint8Array(hkdfSync("sha256", deviceSecret, "", "smrt-vault-device-key-v1", KEY_BYTES));
|
|
11
|
+
}
|
|
12
|
+
function wrapVaultDeviceKey(dataKey, deviceSecret, { accountId, deviceId, generation, keyId }) {
|
|
13
|
+
if (!(dataKey instanceof Uint8Array) || dataKey.length !== KEY_BYTES) throw new Error("Vault data key must be 32 bytes");
|
|
14
|
+
const wrappingKey = deriveDeviceWrappingKey(deviceSecret);
|
|
15
|
+
try {
|
|
16
|
+
const envelope = sealVaultSnapshot(dataKey, {
|
|
17
|
+
context: {
|
|
18
|
+
accountId,
|
|
19
|
+
deviceId,
|
|
20
|
+
sequence: generation
|
|
21
|
+
},
|
|
22
|
+
key: wrappingKey,
|
|
23
|
+
keyId
|
|
24
|
+
});
|
|
25
|
+
return Buffer.concat([RECORD_PREFIX, envelope]);
|
|
26
|
+
} finally {
|
|
27
|
+
wrappingKey.fill(0);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function openVaultDeviceKey(record, deviceSecret, { accountId, deviceId, generation, keyId }) {
|
|
31
|
+
if (!(record instanceof Uint8Array) || record.length <= RECORD_PREFIX.length || record.length > MAX_RECORD_BYTES) throw new Error("Invalid vault device key record");
|
|
32
|
+
const bytes = Buffer.from(record);
|
|
33
|
+
const envelope = bytes.subarray(RECORD_PREFIX.length);
|
|
34
|
+
if (!bytes.subarray(0, RECORD_PREFIX.length).equals(RECORD_PREFIX) || !envelope.subarray(0, ENVELOPE_PREFIX.length).equals(ENVELOPE_PREFIX)) throw new Error("Unsupported vault device key format");
|
|
35
|
+
const wrappingKey = deriveDeviceWrappingKey(deviceSecret);
|
|
36
|
+
try {
|
|
37
|
+
const dataKey = openVaultSnapshot(envelope, {
|
|
38
|
+
context: {
|
|
39
|
+
accountId,
|
|
40
|
+
deviceId,
|
|
41
|
+
sequence: generation
|
|
42
|
+
},
|
|
43
|
+
resolveKey: (recordKeyId) => recordKeyId === keyId ? wrappingKey : void 0
|
|
44
|
+
});
|
|
45
|
+
if (dataKey.length !== KEY_BYTES) {
|
|
46
|
+
dataKey.fill(0);
|
|
47
|
+
throw new Error("Invalid vault data key size");
|
|
48
|
+
}
|
|
49
|
+
return dataKey;
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error("Vault device key authentication failed");
|
|
52
|
+
} finally {
|
|
53
|
+
wrappingKey.fill(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { openVaultDeviceKey, wrapVaultDeviceKey };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=vault-device-key.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vault-device-key.js","names":[],"sources":["../src/vault-device-key.ts"],"sourcesContent":["import { hkdfSync } from 'node:crypto';\nimport {\n openVaultSnapshot,\n sealVaultSnapshot,\n} from '@happyvertical/encryption/vault-snapshot';\n\nconst RECORD_PREFIX = Buffer.of(0x48, 0x56, 0x44, 0x4b, 1); // HVDK v1\nconst ENVELOPE_PREFIX = Buffer.of(0x48, 0x56, 0x53, 0x45, 1, 1, 0);\nconst KEY_BYTES = 32;\n// SDK v1 allows two 255-byte IDs. With the fixed key payload and no\n// compression, every valid record fits inside this bound.\nconst MAX_RECORD_BYTES = 672;\n\nexport interface VaultDeviceKeyContext {\n accountId: string;\n deviceId: string;\n generation: bigint;\n keyId: string;\n}\n\nfunction deriveDeviceWrappingKey(deviceSecret: Uint8Array): Uint8Array {\n if (\n !(deviceSecret instanceof Uint8Array) ||\n deviceSecret.length !== KEY_BYTES\n ) {\n throw new Error('Vault device secret must be 32 bytes');\n }\n return new Uint8Array(\n hkdfSync('sha256', deviceSecret, '', 'smrt-vault-device-key-v1', KEY_BYTES),\n );\n}\n\n/** Wrap the account data key with an independently generated device secret. */\nexport function wrapVaultDeviceKey(\n dataKey: Uint8Array,\n deviceSecret: Uint8Array,\n { accountId, deviceId, generation, keyId }: VaultDeviceKeyContext,\n): Uint8Array {\n if (!(dataKey instanceof Uint8Array) || dataKey.length !== KEY_BYTES) {\n throw new Error('Vault data key must be 32 bytes');\n }\n\n const wrappingKey = deriveDeviceWrappingKey(deviceSecret);\n try {\n const envelope = sealVaultSnapshot(dataKey, {\n context: { accountId, deviceId, sequence: generation },\n key: wrappingKey,\n keyId,\n });\n return Buffer.concat([RECORD_PREFIX, envelope]);\n } finally {\n wrappingKey.fill(0);\n }\n}\n\n/** Open a device record against the caller's trusted key and device context. */\nexport function openVaultDeviceKey(\n record: Uint8Array,\n deviceSecret: Uint8Array,\n { accountId, deviceId, generation, keyId }: VaultDeviceKeyContext,\n): Uint8Array {\n if (\n !(record instanceof Uint8Array) ||\n record.length <= RECORD_PREFIX.length ||\n record.length > MAX_RECORD_BYTES\n ) {\n throw new Error('Invalid vault device key record');\n }\n const bytes = Buffer.from(record);\n const envelope = bytes.subarray(RECORD_PREFIX.length);\n if (\n !bytes.subarray(0, RECORD_PREFIX.length).equals(RECORD_PREFIX) ||\n !envelope.subarray(0, ENVELOPE_PREFIX.length).equals(ENVELOPE_PREFIX)\n ) {\n throw new Error('Unsupported vault device key format');\n }\n\n const wrappingKey = deriveDeviceWrappingKey(deviceSecret);\n try {\n const dataKey = openVaultSnapshot(envelope, {\n context: { accountId, deviceId, sequence: generation },\n resolveKey: (recordKeyId) =>\n recordKeyId === keyId ? wrappingKey : undefined,\n });\n if (dataKey.length !== KEY_BYTES) {\n dataKey.fill(0);\n throw new Error('Invalid vault data key size');\n }\n return dataKey;\n } catch {\n throw new Error('Vault device key authentication failed');\n } finally {\n wrappingKey.fill(0);\n }\n}\n"],"mappings":";;;AAMA,IAAM,gBAAgB,OAAO,GAAG,IAAM,IAAM,IAAM,IAAM,CAAC;AACzD,IAAM,kBAAkB,OAAO,GAAG,IAAM,IAAM,IAAM,IAAM,GAAG,GAAG,CAAC;AACjE,IAAM,YAAY;AAGlB,IAAM,mBAAmB;AASzB,SAAS,wBAAwB,cAAsC;CACrE,IACE,EAAE,wBAAwB,eAC1B,aAAa,WAAW,WAExB,MAAM,IAAI,MAAM,sCAAsC;CAExD,OAAO,IAAI,WACT,SAAS,UAAU,cAAc,IAAI,4BAA4B,SAAS,CAC5E;AACF;AAGO,SAAS,mBACd,SACA,cACA,EAAE,WAAW,UAAU,YAAY,SACvB;CACZ,IAAI,EAAE,mBAAmB,eAAe,QAAQ,WAAW,WACzD,MAAM,IAAI,MAAM,iCAAiC;CAGnD,MAAM,cAAc,wBAAwB,YAAY;CACxD,IAAI;EACF,MAAM,WAAW,kBAAkB,SAAS;GAC1C,SAAS;IAAE;IAAW;IAAU,UAAU;GAAW;GACrD,KAAK;GACL;EACF,CAAC;EACD,OAAO,OAAO,OAAO,CAAC,eAAe,QAAQ,CAAC;CAChD,UAAE;EACA,YAAY,KAAK,CAAC;CACpB;AACF;AAGO,SAAS,mBACd,QACA,cACA,EAAE,WAAW,UAAU,YAAY,SACvB;CACZ,IACE,EAAE,kBAAkB,eACpB,OAAO,UAAU,cAAc,UAC/B,OAAO,SAAS,kBAEhB,MAAM,IAAI,MAAM,iCAAiC;CAEnD,MAAM,QAAQ,OAAO,KAAK,MAAM;CAChC,MAAM,WAAW,MAAM,SAAS,cAAc,MAAM;CACpD,IACE,CAAC,MAAM,SAAS,GAAG,cAAc,MAAM,CAAA,CAAE,OAAO,aAAa,KAC7D,CAAC,SAAS,SAAS,GAAG,gBAAgB,MAAM,CAAA,CAAE,OAAO,eAAe,GAEpE,MAAM,IAAI,MAAM,qCAAqC;CAGvD,MAAM,cAAc,wBAAwB,YAAY;CACxD,IAAI;EACF,MAAM,UAAU,kBAAkB,UAAU;GAC1C,SAAS;IAAE;IAAW;IAAU,UAAU;GAAW;GACrD,aAAa,gBACX,gBAAgB,QAAQ,cAAc,KAAA;EAC1C,CAAC;EACD,IAAI,QAAQ,WAAW,WAAW;GAChC,QAAQ,KAAK,CAAC;GACd,MAAM,IAAI,MAAM,6BAA6B;EAC/C;EACA,OAAO;CACT,QAAQ;EACN,MAAM,IAAI,MAAM,wCAAwC;CAC1D,UAAE;EACA,YAAY,KAAK,CAAC;CACpB;AACF"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface VaultRecoveryContext {
|
|
2
|
+
accountId: string;
|
|
3
|
+
generation: bigint;
|
|
4
|
+
}
|
|
5
|
+
export interface VaultRecoveryDataKey {
|
|
6
|
+
keyId: string;
|
|
7
|
+
key: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
/** Protect a vault data key using a recovery secret held by the user. */
|
|
10
|
+
export declare function wrapVaultRecoveryKey({ keyId, key }: VaultRecoveryDataKey, recoverySecret: string, context: VaultRecoveryContext): Promise<Uint8Array>;
|
|
11
|
+
/** Recover a key using the expected account and generation from local state. */
|
|
12
|
+
export declare function openVaultRecoveryKey(record: Uint8Array, recoverySecret: string, context: VaultRecoveryContext): Promise<VaultRecoveryDataKey>;
|
|
13
|
+
//# sourceMappingURL=vault-recovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vault-recovery.d.ts","sourceRoot":"","sources":["../src/vault-recovery.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,UAAU,CAAC;CACjB;AA0DD,yEAAyE;AACzE,wBAAsB,oBAAoB,CACxC,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,oBAAoB,EACpC,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,UAAU,CAAC,CA8BrB;AAED,gFAAgF;AAChF,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,UAAU,EAClB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,oBAAoB,CAAC,CAgD/B"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomBytes, scrypt } from "node:crypto";
|
|
2
|
+
import { openVaultSnapshot, sealVaultSnapshot } from "@happyvertical/encryption/vault-snapshot";
|
|
3
|
+
//#region src/vault-recovery.ts
|
|
4
|
+
var MAGIC = Buffer.from("HVRK");
|
|
5
|
+
var VERSION = 1;
|
|
6
|
+
var SALT_BYTES = 16;
|
|
7
|
+
var SALT_OFFSET = MAGIC.length + 1;
|
|
8
|
+
var ENVELOPE_OFFSET = SALT_OFFSET + SALT_BYTES;
|
|
9
|
+
var KEY_BYTES = 32;
|
|
10
|
+
var MAX_GENERATION = (1n << 64n) - 1n;
|
|
11
|
+
var RECOVERY_DOMAIN = "smrt-vault-recovery-v1";
|
|
12
|
+
var V1_ENVELOPE_PREFIX = Buffer.of(72, 86, 83, 69, 1, 1, 0);
|
|
13
|
+
var V1_RECORD_FIXED_BYTES = ENVELOPE_OFFSET + 94 + 16 + 10 + 22 + KEY_BYTES;
|
|
14
|
+
var MAX_RECORD_BYTES = V1_RECORD_FIXED_BYTES + 255;
|
|
15
|
+
var LONE_SURROGATE = /[\ud800-\udfff]/u;
|
|
16
|
+
function snapshotContext({ accountId, generation }) {
|
|
17
|
+
if (typeof accountId !== "string" || accountId.length === 0 || accountId.length > 255 || Buffer.byteLength(accountId) > 255 || LONE_SURROGATE.test(accountId)) throw new Error("Invalid vault recovery account ID");
|
|
18
|
+
if (typeof generation !== "bigint" || generation < 0n || generation > MAX_GENERATION) throw new Error("Invalid vault recovery generation");
|
|
19
|
+
return {
|
|
20
|
+
accountId,
|
|
21
|
+
deviceId: RECOVERY_DOMAIN,
|
|
22
|
+
sequence: generation
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function deriveWrappingKey(recoverySecret, salt) {
|
|
26
|
+
if (typeof recoverySecret !== "string" || recoverySecret.length > 1024 || LONE_SURROGATE.test(recoverySecret) || Buffer.byteLength(recoverySecret) < 16 || Buffer.byteLength(recoverySecret) > 1024) throw new Error("Vault recovery secret must be 16 to 1024 UTF-8 bytes");
|
|
27
|
+
const secretBytes = Buffer.from(recoverySecret);
|
|
28
|
+
const domainSalt = Buffer.concat([Buffer.from(RECOVERY_DOMAIN), salt]);
|
|
29
|
+
try {
|
|
30
|
+
return await new Promise((resolve, reject) => {
|
|
31
|
+
scrypt(secretBytes, domainSalt, KEY_BYTES, {
|
|
32
|
+
N: 2 ** 17,
|
|
33
|
+
r: 8,
|
|
34
|
+
p: 1,
|
|
35
|
+
maxmem: 160 * 1024 * 1024
|
|
36
|
+
}, (error, key) => {
|
|
37
|
+
if (error) reject(error);
|
|
38
|
+
else resolve(key);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
} finally {
|
|
42
|
+
secretBytes.fill(0);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function wrapVaultRecoveryKey({ keyId, key }, recoverySecret, context) {
|
|
46
|
+
if (typeof keyId !== "string" || keyId.length !== 32 || !/^[0-9a-f]{32}$/.test(keyId)) throw new Error("Invalid vault recovery key ID");
|
|
47
|
+
if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) throw new Error("Vault data key must be 32 bytes");
|
|
48
|
+
const trustedContext = snapshotContext(context);
|
|
49
|
+
const salt = randomBytes(SALT_BYTES);
|
|
50
|
+
const dataKey = Buffer.from(key);
|
|
51
|
+
try {
|
|
52
|
+
const wrappingKey = await deriveWrappingKey(recoverySecret, salt);
|
|
53
|
+
try {
|
|
54
|
+
const envelope = sealVaultSnapshot(dataKey, {
|
|
55
|
+
context: trustedContext,
|
|
56
|
+
key: wrappingKey,
|
|
57
|
+
keyId
|
|
58
|
+
});
|
|
59
|
+
return Buffer.concat([
|
|
60
|
+
MAGIC,
|
|
61
|
+
Buffer.of(VERSION),
|
|
62
|
+
salt,
|
|
63
|
+
envelope
|
|
64
|
+
]);
|
|
65
|
+
} finally {
|
|
66
|
+
wrappingKey.fill(0);
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
dataKey.fill(0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function openVaultRecoveryKey(record, recoverySecret, context) {
|
|
73
|
+
if (!(record instanceof Uint8Array) || record.length <= ENVELOPE_OFFSET || record.length > MAX_RECORD_BYTES) throw new Error("Invalid vault recovery record");
|
|
74
|
+
const bytes = Buffer.from(record);
|
|
75
|
+
if (!bytes.subarray(0, MAGIC.length).equals(MAGIC) || bytes[MAGIC.length] !== VERSION) throw new Error("Unsupported vault recovery format");
|
|
76
|
+
const trustedContext = snapshotContext(context);
|
|
77
|
+
const envelope = bytes.subarray(ENVELOPE_OFFSET);
|
|
78
|
+
if (bytes.length !== V1_RECORD_FIXED_BYTES + Buffer.byteLength(trustedContext.accountId) || !envelope.subarray(0, V1_ENVELOPE_PREFIX.length).equals(V1_ENVELOPE_PREFIX)) throw new Error("Invalid vault recovery record");
|
|
79
|
+
const wrappingKey = await deriveWrappingKey(recoverySecret, bytes.subarray(SALT_OFFSET, ENVELOPE_OFFSET));
|
|
80
|
+
let keyId = "";
|
|
81
|
+
try {
|
|
82
|
+
const key = openVaultSnapshot(envelope, {
|
|
83
|
+
context: trustedContext,
|
|
84
|
+
resolveKey(id) {
|
|
85
|
+
keyId = id;
|
|
86
|
+
return wrappingKey;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (key.length !== KEY_BYTES) {
|
|
90
|
+
key.fill(0);
|
|
91
|
+
throw new Error("Invalid recovered key size");
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
keyId,
|
|
95
|
+
key
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error("Vault recovery authentication failed");
|
|
99
|
+
} finally {
|
|
100
|
+
wrappingKey.fill(0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
export { openVaultRecoveryKey, wrapVaultRecoveryKey };
|
|
105
|
+
|
|
106
|
+
//# sourceMappingURL=vault-recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vault-recovery.js","names":[],"sources":["../src/vault-recovery.ts"],"sourcesContent":["import { randomBytes, scrypt } from 'node:crypto';\nimport {\n openVaultSnapshot,\n sealVaultSnapshot,\n type VaultSnapshotContext,\n} from '@happyvertical/encryption/vault-snapshot';\n\nconst MAGIC = Buffer.from('HVRK');\nconst VERSION = 1;\nconst SALT_BYTES = 16;\nconst SALT_OFFSET = MAGIC.length + 1;\nconst ENVELOPE_OFFSET = SALT_OFFSET + SALT_BYTES;\nconst KEY_BYTES = 32;\nconst MAX_GENERATION = (1n << 64n) - 1n;\nconst RECOVERY_DOMAIN = 'smrt-vault-recovery-v1';\nconst V1_ENVELOPE_PREFIX = Buffer.of(0x48, 0x56, 0x53, 0x45, 1, 1, 0);\n// SDK v1: two 47-byte headers, 16-byte secretbox tag, two one-byte\n// ID lengths and an eight-byte sequence, plus the device ID and data key.\nconst V1_RECORD_FIXED_BYTES =\n ENVELOPE_OFFSET + 2 * 47 + 16 + 10 + RECOVERY_DOMAIN.length + KEY_BYTES;\nconst MAX_RECORD_BYTES = V1_RECORD_FIXED_BYTES + 255;\n// In Unicode mode, a valid surrogate pair is matched as one code point.\nconst LONE_SURROGATE = /[\\ud800-\\udfff]/u;\n\nexport interface VaultRecoveryContext {\n accountId: string;\n generation: bigint;\n}\n\nexport interface VaultRecoveryDataKey {\n keyId: string;\n key: Uint8Array;\n}\n\nfunction snapshotContext({\n accountId,\n generation,\n}: VaultRecoveryContext): VaultSnapshotContext {\n if (\n typeof accountId !== 'string' ||\n accountId.length === 0 ||\n accountId.length > 255 ||\n Buffer.byteLength(accountId) > 255 ||\n LONE_SURROGATE.test(accountId)\n ) {\n throw new Error('Invalid vault recovery account ID');\n }\n if (\n typeof generation !== 'bigint' ||\n generation < 0n ||\n generation > MAX_GENERATION\n ) {\n throw new Error('Invalid vault recovery generation');\n }\n return { accountId, deviceId: RECOVERY_DOMAIN, sequence: generation };\n}\n\nasync function deriveWrappingKey(\n recoverySecret: string,\n salt: Uint8Array,\n): Promise<Buffer> {\n if (\n typeof recoverySecret !== 'string' ||\n recoverySecret.length > 1024 ||\n LONE_SURROGATE.test(recoverySecret) ||\n Buffer.byteLength(recoverySecret) < 16 ||\n Buffer.byteLength(recoverySecret) > 1024\n ) {\n throw new Error('Vault recovery secret must be 16 to 1024 UTF-8 bytes');\n }\n const secretBytes = Buffer.from(recoverySecret);\n const domainSalt = Buffer.concat([Buffer.from(RECOVERY_DOMAIN), salt]);\n try {\n return await new Promise<Buffer>((resolve, reject) => {\n scrypt(\n secretBytes,\n domainSalt,\n KEY_BYTES,\n { N: 2 ** 17, r: 8, p: 1, maxmem: 160 * 1024 * 1024 },\n (error, key) => {\n if (error) reject(error);\n else resolve(key);\n },\n );\n });\n } finally {\n secretBytes.fill(0);\n }\n}\n\n/** Protect a vault data key using a recovery secret held by the user. */\nexport async function wrapVaultRecoveryKey(\n { keyId, key }: VaultRecoveryDataKey,\n recoverySecret: string,\n context: VaultRecoveryContext,\n): Promise<Uint8Array> {\n if (\n typeof keyId !== 'string' ||\n keyId.length !== 32 ||\n !/^[0-9a-f]{32}$/.test(keyId)\n ) {\n throw new Error('Invalid vault recovery key ID');\n }\n if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) {\n throw new Error('Vault data key must be 32 bytes');\n }\n\n const trustedContext = snapshotContext(context);\n const salt = randomBytes(SALT_BYTES);\n const dataKey = Buffer.from(key);\n try {\n const wrappingKey = await deriveWrappingKey(recoverySecret, salt);\n try {\n const envelope = sealVaultSnapshot(dataKey, {\n context: trustedContext,\n key: wrappingKey,\n keyId,\n });\n return Buffer.concat([MAGIC, Buffer.of(VERSION), salt, envelope]);\n } finally {\n wrappingKey.fill(0);\n }\n } finally {\n dataKey.fill(0);\n }\n}\n\n/** Recover a key using the expected account and generation from local state. */\nexport async function openVaultRecoveryKey(\n record: Uint8Array,\n recoverySecret: string,\n context: VaultRecoveryContext,\n): Promise<VaultRecoveryDataKey> {\n if (\n !(record instanceof Uint8Array) ||\n record.length <= ENVELOPE_OFFSET ||\n record.length > MAX_RECORD_BYTES\n ) {\n throw new Error('Invalid vault recovery record');\n }\n const bytes = Buffer.from(record);\n if (\n !bytes.subarray(0, MAGIC.length).equals(MAGIC) ||\n bytes[MAGIC.length] !== VERSION\n ) {\n throw new Error('Unsupported vault recovery format');\n }\n\n const trustedContext = snapshotContext(context);\n const envelope = bytes.subarray(ENVELOPE_OFFSET);\n if (\n bytes.length !==\n V1_RECORD_FIXED_BYTES + Buffer.byteLength(trustedContext.accountId) ||\n !envelope.subarray(0, V1_ENVELOPE_PREFIX.length).equals(V1_ENVELOPE_PREFIX)\n ) {\n throw new Error('Invalid vault recovery record');\n }\n const wrappingKey = await deriveWrappingKey(\n recoverySecret,\n bytes.subarray(SALT_OFFSET, ENVELOPE_OFFSET),\n );\n let keyId = '';\n try {\n const key = openVaultSnapshot(envelope, {\n context: trustedContext,\n resolveKey(id) {\n keyId = id;\n return wrappingKey;\n },\n });\n if (key.length !== KEY_BYTES) {\n key.fill(0);\n throw new Error('Invalid recovered key size');\n }\n return { keyId, key };\n } catch {\n throw new Error('Vault recovery authentication failed');\n } finally {\n wrappingKey.fill(0);\n }\n}\n"],"mappings":";;;AAOA,IAAM,QAAQ,OAAO,KAAK,MAAM;AAChC,IAAM,UAAU;AAChB,IAAM,aAAa;AACnB,IAAM,cAAc,MAAM,SAAS;AACnC,IAAM,kBAAkB,cAAc;AACtC,IAAM,YAAY;AAClB,IAAM,kBAAkB,MAAM,OAAO;AACrC,IAAM,kBAAkB;AACxB,IAAM,qBAAqB,OAAO,GAAG,IAAM,IAAM,IAAM,IAAM,GAAG,GAAG,CAAC;AAGpE,IAAM,wBACJ,kBAAkB,KAAS,KAAK,KAAK,KAAyB;AAChE,IAAM,mBAAmB,wBAAwB;AAEjD,IAAM,iBAAiB;AAYvB,SAAS,gBAAgB,EACvB,WACA,cAC6C;CAC7C,IACE,OAAO,cAAc,YACrB,UAAU,WAAW,KACrB,UAAU,SAAS,OACnB,OAAO,WAAW,SAAS,IAAI,OAC/B,eAAe,KAAK,SAAS,GAE7B,MAAM,IAAI,MAAM,mCAAmC;CAErD,IACE,OAAO,eAAe,YACtB,aAAa,MACb,aAAa,gBAEb,MAAM,IAAI,MAAM,mCAAmC;CAErD,OAAO;EAAE;EAAW,UAAU;EAAiB,UAAU;CAAW;AACtE;AAEA,eAAe,kBACb,gBACA,MACiB;CACjB,IACE,OAAO,mBAAmB,YAC1B,eAAe,SAAS,QACxB,eAAe,KAAK,cAAc,KAClC,OAAO,WAAW,cAAc,IAAI,MACpC,OAAO,WAAW,cAAc,IAAI,MAEpC,MAAM,IAAI,MAAM,sDAAsD;CAExE,MAAM,cAAc,OAAO,KAAK,cAAc;CAC9C,MAAM,aAAa,OAAO,OAAO,CAAC,OAAO,KAAK,eAAe,GAAG,IAAI,CAAC;CACrE,IAAI;EACF,OAAO,MAAM,IAAI,SAAiB,SAAS,WAAW;GACpD,OACE,aACA,YACA,WACA;IAAE,GAAG,KAAK;IAAI,GAAG;IAAG,GAAG;IAAG,QAAQ,MAAM,OAAO;GAAK,IACnD,OAAO,QAAQ;IACd,IAAI,OAAO,OAAO,KAAK;SAClB,QAAQ,GAAG;GAClB,CACF;EACF,CAAC;CACH,UAAE;EACA,YAAY,KAAK,CAAC;CACpB;AACF;AAGA,eAAsB,qBACpB,EAAE,OAAO,OACT,gBACA,SACqB;CACrB,IACE,OAAO,UAAU,YACjB,MAAM,WAAW,MACjB,CAAC,iBAAiB,KAAK,KAAK,GAE5B,MAAM,IAAI,MAAM,+BAA+B;CAEjD,IAAI,EAAE,eAAe,eAAe,IAAI,WAAW,WACjD,MAAM,IAAI,MAAM,iCAAiC;CAGnD,MAAM,iBAAiB,gBAAgB,OAAO;CAC9C,MAAM,OAAO,YAAY,UAAU;CACnC,MAAM,UAAU,OAAO,KAAK,GAAG;CAC/B,IAAI;EACF,MAAM,cAAc,MAAM,kBAAkB,gBAAgB,IAAI;EAChE,IAAI;GACF,MAAM,WAAW,kBAAkB,SAAS;IAC1C,SAAS;IACT,KAAK;IACL;GACF,CAAC;GACD,OAAO,OAAO,OAAO;IAAC;IAAO,OAAO,GAAG,OAAO;IAAG;IAAM;GAAQ,CAAC;EAClE,UAAE;GACA,YAAY,KAAK,CAAC;EACpB;CACF,UAAE;EACA,QAAQ,KAAK,CAAC;CAChB;AACF;AAGA,eAAsB,qBACpB,QACA,gBACA,SAC+B;CAC/B,IACE,EAAE,kBAAkB,eACpB,OAAO,UAAU,mBACjB,OAAO,SAAS,kBAEhB,MAAM,IAAI,MAAM,+BAA+B;CAEjD,MAAM,QAAQ,OAAO,KAAK,MAAM;CAChC,IACE,CAAC,MAAM,SAAS,GAAG,MAAM,MAAM,CAAA,CAAE,OAAO,KAAK,KAC7C,MAAM,MAAM,YAAY,SAExB,MAAM,IAAI,MAAM,mCAAmC;CAGrD,MAAM,iBAAiB,gBAAgB,OAAO;CAC9C,MAAM,WAAW,MAAM,SAAS,eAAe;CAC/C,IACE,MAAM,WACJ,wBAAwB,OAAO,WAAW,eAAe,SAAS,KACpE,CAAC,SAAS,SAAS,GAAG,mBAAmB,MAAM,CAAA,CAAE,OAAO,kBAAkB,GAE1E,MAAM,IAAI,MAAM,+BAA+B;CAEjD,MAAM,cAAc,MAAM,kBACxB,gBACA,MAAM,SAAS,aAAa,eAAe,CAC7C;CACA,IAAI,QAAQ;CACZ,IAAI;EACF,MAAM,MAAM,kBAAkB,UAAU;GACtC,SAAS;GACT,WAAW,IAAI;IACb,QAAQ;IACR,OAAO;GACT;EACF,CAAC;EACD,IAAI,IAAI,WAAW,WAAW;GAC5B,IAAI,KAAK,CAAC;GACV,MAAM,IAAI,MAAM,4BAA4B;EAC9C;EACA,OAAO;GAAE;GAAO;EAAI;CACtB,QAAQ;EACN,MAAM,IAAI,MAAM,sCAAsC;CACxD,UAAE;EACA,YAAY,KAAK,CAAC;CACpB;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-app-runtime",
|
|
3
|
-
"version": "0.60.
|
|
3
|
+
"version": "0.60.13",
|
|
4
4
|
"description": "Validated local, self-hosted, and cloud runtime composition for s-m-r-t applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"./sveltekit": {
|
|
18
18
|
"types": "./dist/sveltekit/index.d.ts",
|
|
19
19
|
"import": "./dist/sveltekit.js"
|
|
20
|
+
},
|
|
21
|
+
"./vault-recovery": {
|
|
22
|
+
"types": "./dist/vault-recovery.d.ts",
|
|
23
|
+
"import": "./dist/vault-recovery.js"
|
|
24
|
+
},
|
|
25
|
+
"./vault-device-key": {
|
|
26
|
+
"types": "./dist/vault-device-key.d.ts",
|
|
27
|
+
"import": "./dist/vault-device-key.js"
|
|
20
28
|
}
|
|
21
29
|
},
|
|
22
30
|
"files": [
|
|
@@ -33,16 +41,17 @@
|
|
|
33
41
|
}
|
|
34
42
|
},
|
|
35
43
|
"dependencies": {
|
|
36
|
-
"@happyvertical/
|
|
37
|
-
"@happyvertical/smrt-
|
|
38
|
-
"@happyvertical/smrt-
|
|
39
|
-
"@happyvertical/smrt-
|
|
40
|
-
"@happyvertical/smrt-
|
|
41
|
-
"@happyvertical/smrt-
|
|
42
|
-
"@happyvertical/
|
|
44
|
+
"@happyvertical/encryption": "^0.111.6",
|
|
45
|
+
"@happyvertical/smrt-config": "0.60.13",
|
|
46
|
+
"@happyvertical/smrt-core": "0.60.13",
|
|
47
|
+
"@happyvertical/smrt-jobs": "0.60.13",
|
|
48
|
+
"@happyvertical/smrt-profiles": "0.60.13",
|
|
49
|
+
"@happyvertical/smrt-tenancy": "0.60.13",
|
|
50
|
+
"@happyvertical/smrt-users": "0.60.13",
|
|
51
|
+
"@happyvertical/sql": "^0.111.6"
|
|
43
52
|
},
|
|
44
53
|
"devDependencies": {
|
|
45
|
-
"@happyvertical/smrt-vitest": "0.60.
|
|
54
|
+
"@happyvertical/smrt-vitest": "0.60.13",
|
|
46
55
|
"@sveltejs/kit": "2.70.3",
|
|
47
56
|
"@types/node": "24.13.6",
|
|
48
57
|
"tsx": "^4.23.13",
|