@happyvertical/smrt-app-runtime 0.60.10 → 0.60.11

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/AGENTS.md CHANGED
@@ -77,7 +77,12 @@ Application infrastructure composition for the validated runtime profiles in
77
77
  `tenantBinding: 'direct'` and the
78
78
  `LOCAL_MCP_TOKEN_PRINCIPAL` brand; `runAsPrincipal` rejects a copy that
79
79
  widens the binding, so the two public APIs compose safely.
80
- Never log or return the token outside `issue()`. The operator store opens an
80
+ Return bearer material only to the issuing operator or the authenticated
81
+ desktop credential response. The opt-in `createLocalMcpCredentialHandler`
82
+ requires a direct loopback peer, matching Host and exact Origin, and the sole
83
+ active owner's first-class session. Desktop slots use a disjoint ID namespace,
84
+ atomic hash replacement and terminal revocation. See the desktop protocol in
85
+ [README.md](README.md). The operator store opens an
81
86
  uncached connection and takes no writer lease (revocation is per request).
82
87
  - Background jobs and application-defined paid capabilities are default-off.
83
88
  - The embedded runner reuses `TaskRunner`; it is not a second job contract.
package/README.md CHANGED
@@ -168,6 +168,73 @@ explicitly enabled. With embedded job topology, `createEmbeddedJobRunner()`
168
168
  returns the normal s-m-r-t `TaskRunner`, so the application keeps one enqueue
169
169
  and execution contract without requiring a separate worker service.
170
170
 
171
+ ### Local desktop MCP credentials
172
+
173
+ A desktop product can mount this opt-in SvelteKit route in
174
+ `src/routes/api/local/mcp-credentials/+server.ts`.
175
+
176
+ ```ts
177
+ import { createLocalMcpCredentialHandler } from '@happyvertical/smrt-app-runtime/sveltekit';
178
+ import { runtime } from '$lib/server/smrt';
179
+
180
+ export const POST = createLocalMcpCredentialHandler(runtime);
181
+ ```
182
+
183
+ Serve it on the desktop app's direct loopback listener, with the adapter's
184
+ socket peer address and request Host. Keep forwarded address, host, and
185
+ protocol configuration unset. Set adapter-node's
186
+ `ORIGIN` to the actual desktop URL, such as `http://127.0.0.1:4678`, because
187
+ adapter-node otherwise assumes HTTPS. The handler checks the socket peer,
188
+ loopback URL host, matching Host and exact Origin. When the browser
189
+ sends `Sec-Fetch-Site`, it must be `same-origin`. Call the endpoint from the
190
+ app's signed-in, same-origin desktop webview using its normal session cookie.
191
+ The session must use local bootstrap, password or passkey authentication,
192
+ have a direct active membership, and match the sole active system Owner
193
+ membership of the installation. Device, layered and permission-capped sessions
194
+ are rejected.
195
+
196
+ Send a JSON POST with an opaque lowercase UUIDv4 `id` generated by the
197
+ desktop app, a nonempty array of permission slugs, and an explicit integer
198
+ lifetime from 60 to 31,536,000 seconds.
199
+
200
+ ```json
201
+ {
202
+ "action": "put",
203
+ "id": "2c70f518-b95a-445b-a75e-8126ae7c032a",
204
+ "scopes": ["notes.read"],
205
+ "expiresInSeconds": 3600
206
+ }
207
+ ```
208
+
209
+ The first PUT creates the slot. A later POST with the same `action: "put"`
210
+ replaces its bearer, scopes and expiry atomically. Scopes must be held by
211
+ both the live owner and the authenticated session. Successful replies carry
212
+ `{ id, token, scopes, expiresAt }` in a private `Cache-Control: no-store`
213
+ response. Pass the bearer directly to native credential handling for the MCP
214
+ bridge. Keep it out of arguments, URLs, logs, browser storage, and generated
215
+ plugin artifacts. The installation secret stays on the server, and the
216
+ database stores only the bearer HMAC.
217
+
218
+ Persist the non-secret slot ID before the first request and serialize actions
219
+ per slot. A failed write preserves the previous credential. If the outcome is
220
+ unknown or the response is lost, repeat PUT with the same ID. Each successful
221
+ write produces a fresh credential, and only the latest committed write
222
+ verifies. A retry therefore recovers the slot without leaving an extra active
223
+ credential.
224
+
225
+ Send `{ "action": "revoke", "id": "<slot-id>" }` with an unrestricted owner
226
+ session to disconnect. Repeating it returns `{ "status": "revoked" }`.
227
+ Revocation is permanent for the slot even if a PUT was delayed or no PUT
228
+ completed, so use a new UUID to reconnect. The operator CLI can list or
229
+ revoke the stored UUIDv8 record. Desktop slots and manual CLI token IDs are
230
+ separate namespaces.
231
+
232
+ Failures carry a fixed `code` and no credentials. A revoked slot returns
233
+ `409 slot_unavailable`; validation failures return `400 invalid_request`
234
+ or `400 invalid_scope`; requests failing the trust checks return
235
+ `403 forbidden`. Deployed profiles return `404 unavailable` to requests
236
+ that reach the profile check.
237
+
171
238
  ## SvelteKit entry
172
239
 
173
240
  `@happyvertical/smrt-app-runtime/sveltekit` composes the profile runtime,
@@ -708,6 +708,8 @@ var MAX_SCOPE_LENGTH = 128;
708
708
  var MAX_LABEL_LENGTH = 80;
709
709
  var SCOPE_PATTERN = /^[\x21\x23-\x5b\x5d-\x7e]+$/u;
710
710
  var TOKEN_PATTERN = /^smrt_mcp_[A-Za-z0-9_-]{43}$/u;
711
+ var DESKTOP_SLOT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
712
+ var DESKTOP_SLOT_LABEL = "Desktop integration";
711
713
  var TOKEN_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
712
714
  var HMAC_DOMAIN = "smrt.local-mcp-token.v1\0";
713
715
  var LOCAL_MCP_TOKEN_PRINCIPAL = /* @__PURE__ */ Symbol.for("@happyvertical/smrt-app-runtime/local-mcp-token-principal");
@@ -764,7 +766,7 @@ function createLocalMcpTokenStore(deps) {
764
766
  if (fields === null || typeof fields !== "object") throw fail("invalid_configuration", "MCP token input must be an object.");
765
767
  const scopes = normalizeScopes(ownValue(fields, "scopes"), fail);
766
768
  const ttl = ownValue(fields, "expiresInSeconds") ?? 2592e3;
767
- if (!Number.isSafeInteger(ttl) || ttl < 60 || ttl > 31536e3) throw fail("invalid_configuration", "MCP token lifetime must be between one minute and 365 days.");
769
+ validateTtl(ttl, fail);
768
770
  const label = normalizeLabel(ownValue(fields, "label"), fail);
769
771
  const owner = await deps.findOwner(db);
770
772
  if (!owner) throw fail("owner_unavailable", "No local owner exists yet; finish owner setup before issuing MCP tokens.");
@@ -789,6 +791,58 @@ function createLocalMcpTokenStore(deps) {
789
791
  expiresAt
790
792
  });
791
793
  },
794
+ async manageDesktopCredential(session, input) {
795
+ let request;
796
+ try {
797
+ request = deepFrozenPlainCopy(input);
798
+ } catch (error) {
799
+ if (!(error instanceof PlainDataError)) throw error;
800
+ throw fail("invalid_configuration", "Invalid desktop MCP input.");
801
+ }
802
+ if (typeof request?.id !== "string" || !DESKTOP_SLOT_ID_PATTERN.test(request.id)) throw fail("invalid_configuration", "Invalid desktop MCP slot id.");
803
+ const id = `${request.id.slice(0, 14)}8${request.id.slice(15)}`;
804
+ const { userId, tenantId, permissions } = session;
805
+ const allowed = new Set(permissions);
806
+ const owner = await deps.findOwner(db, true);
807
+ if (!owner || owner.userId !== userId || owner.tenantId !== tenantId) throw fail("owner_unavailable", "The local owner session is required.");
808
+ const user = await (await UserCollection.create({ db })).get({ id: owner.userId });
809
+ const held = await livePermissions(owner.userId, owner.tenantId);
810
+ if (!user?.isActive() || !held) throw fail("owner_unavailable", "The local owner session is required.");
811
+ const created = now();
812
+ const createdAt = created.toISOString();
813
+ if (request.action === "revoke") return (await db.query(`INSERT INTO _smrt_local_mcp_tokens
814
+ (id, token_hash, user_id, tenant_id, scopes, label, created_at, expires_at, revoked_at)
815
+ VALUES (?, ?, ?, ?, '[]', ?, ?, ?, ?)
816
+ ON CONFLICT(id) DO UPDATE SET revoked_at =
817
+ coalesce(_smrt_local_mcp_tokens.revoked_at, excluded.revoked_at)
818
+ WHERE _smrt_local_mcp_tokens.user_id = excluded.user_id
819
+ AND _smrt_local_mcp_tokens.tenant_id = excluded.tenant_id
820
+ RETURNING id`, id, randomBytes(32).toString("hex"), owner.userId, owner.tenantId, DESKTOP_SLOT_LABEL, createdAt, createdAt, createdAt)).rows.length > 0 ? "revoked" : "slot_unavailable";
821
+ if (request.action !== "put") throw fail("invalid_configuration", "Invalid desktop MCP action.");
822
+ const scopes = normalizeScopes(request.scopes, fail);
823
+ validateTtl(request.expiresInSeconds, fail);
824
+ if (scopes.some((scope) => !held.has(scope) || !allowed.has(scope))) throw fail("invalid_scope", "Desktop MCP scopes are not authorized.");
825
+ const expiresAt = new Date(created.getTime() + request.expiresInSeconds * 1e3).toISOString();
826
+ const token = `${LOCAL_MCP_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
827
+ if ((await db.query(`INSERT INTO _smrt_local_mcp_tokens
828
+ (id, token_hash, user_id, tenant_id, scopes, label, created_at, expires_at)
829
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
830
+ ON CONFLICT(id) DO UPDATE SET
831
+ token_hash = excluded.token_hash, scopes = excluded.scopes,
832
+ created_at = excluded.created_at, expires_at = excluded.expires_at
833
+ WHERE _smrt_local_mcp_tokens.revoked_at IS NULL
834
+ AND _smrt_local_mcp_tokens.user_id = excluded.user_id
835
+ AND _smrt_local_mcp_tokens.tenant_id = excluded.tenant_id
836
+ RETURNING id`, id, await hashToken(token), owner.userId, owner.tenantId, JSON.stringify(scopes), DESKTOP_SLOT_LABEL, createdAt, expiresAt)).rows.length === 0) return "slot_unavailable";
837
+ return Object.freeze({
838
+ id,
839
+ token,
840
+ scopes: Object.freeze(scopes),
841
+ label: DESKTOP_SLOT_LABEL,
842
+ createdAt,
843
+ expiresAt
844
+ });
845
+ },
792
846
  async list() {
793
847
  await ensureLocalMcpTokenTable(db);
794
848
  const result = await db.query(`SELECT id, scopes, label, created_at, expires_at, revoked_at
@@ -848,6 +902,9 @@ function createLocalMcpTokenStore(deps) {
848
902
  }
849
903
  });
850
904
  }
905
+ function validateTtl(ttl, fail) {
906
+ if (!Number.isSafeInteger(ttl) || ttl < 60 || ttl > 31536e3) throw fail("invalid_configuration", "MCP token lifetime must be between one minute and 365 days.");
907
+ }
851
908
  function normalizeScopes(value, fail) {
852
909
  if (!Array.isArray(value) || value.length === 0) throw fail("invalid_scope", "An MCP token needs at least one scope.");
853
910
  if (value.length > MAX_SCOPES) throw fail("invalid_scope", "An MCP token may carry at most 64 scopes.");
@@ -2063,11 +2120,12 @@ function resolveOwnerTenant(options) {
2063
2120
  adoptExisting: options?.adoptExisting === true
2064
2121
  });
2065
2122
  }
2066
- async function findExistingOwner(db) {
2123
+ async function findExistingOwner(db, requireUnique = false) {
2067
2124
  const ownerRole = await (await RoleCollection.create({ db })).findSystemRoleBySlug(DEFAULT_ROLE_SLUGS.OWNER);
2068
2125
  if (!ownerRole?.id) return null;
2069
- const active = (await (await MembershipCollection.create({ db })).findByRole(ownerRole.id)).find((membership) => membership.status === MembershipStatus.ACTIVE && typeof membership.userId === "string" && typeof membership.tenantId === "string");
2070
- return active ? {
2126
+ const activeOwners = (await (await MembershipCollection.create({ db })).findByRole(ownerRole.id)).filter((membership) => membership.status === MembershipStatus.ACTIVE && typeof membership.userId === "string" && typeof membership.tenantId === "string");
2127
+ const active = activeOwners[0];
2128
+ return active && (!requireUnique || activeOwners.length === 1) ? {
2071
2129
  userId: active.userId,
2072
2130
  tenantId: active.tenantId
2073
2131
  } : null;
@@ -2817,4 +2875,4 @@ function isDirectoryNotEmpty(error) {
2817
2875
  //#endregion
2818
2876
  export { reclaimStaleRecord as A, deepFrozenPlainCopy as B, findInstalledPackage as C, processExists as D, lockOwnerPid as E, LOCAL_MCP_TOKEN_PRINCIPAL as F, initializeDeployedApplicationRuntime as G, resolveBoundMembershipPermissions as H, LOCAL_MCP_TOKEN_TABLE as I, isSessionTenantAuthorized as K, MAX_LOCAL_MCP_TOKEN_TTL_SECONDS as L, runtimeConfigurationFingerprint as M, DEFAULT_LOCAL_MCP_TOKEN_TTL_SECONDS as N, withOperationLock as O, LOCAL_MCP_TOKEN_PREFIX as P, isLocalMcpTokenPrincipal as R, createProviderReadinessProbe as S, isStaleOperationLock as T, DeployedRuntimeCleanupError as U, ownValue as V, DeployedRuntimeError as W, MAX_RUNTIME_DIAGNOSTIC_TOOLS as _, openLocalMcpTokenStore as a, projectRuntimeDiagnostics as b, validateApplicationId as c, readActiveWriterLease as d, ONBOARDING_HANDOFF_FILES as f, MAX_RUNTIME_DIAGNOSTIC_ERRORS as g, resolveApplicationStateRoot as h, initializeLocalApplicationRuntime as i, resolveApplicationId as j, RECLAIM_MUTEX_FILE as k, validateLocalDatabaseStorage as l, removeOnboardingHandoff as m, MIGRATION_FAILED_MESSAGE as n, prepareLocalDatabaseStorage as o, prepareApplicationStateRoot as p, encodeApplicationId as r, resolveLocalRuntimePaths as s, LocalRuntimeError as t, acquireWriterLease as u, RUNTIME_DIAGNOSTICS_SCHEMA_VERSION as v, resolveReadinessModule as w, PROVIDER_READINESS_SETTINGS as x, RUNTIME_WORKER_FRESHNESS_SECONDS as y, PlainDataError as z };
2819
2877
 
2820
- //# sourceMappingURL=src-DOepXMDO.js.map
2878
+ //# sourceMappingURL=src-DG72tyzB.js.map