@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 +6 -1
- package/README.md +67 -0
- package/dist/chunks/{src-DOepXMDO.js → src-DG72tyzB.js} +63 -5
- package/dist/chunks/src-DG72tyzB.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/local-mcp-tokens.d.ts +19 -1
- package/dist/local-mcp-tokens.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/smrt-knowledge.json +13 -5
- package/dist/smrt-knowledge.okf.md +6 -2
- package/dist/sveltekit/index.d.ts +1 -0
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/local-mcp-credentials.d.ts +10 -0
- package/dist/sveltekit/local-mcp-credentials.d.ts.map +1 -0
- package/dist/sveltekit-runtime.js +1 -1
- package/dist/sveltekit.js +96 -1
- package/dist/sveltekit.js.map +1 -1
- package/package.json +8 -8
- package/dist/chunks/src-DOepXMDO.js.map +0 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
2070
|
-
|
|
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-
|
|
2878
|
+
//# sourceMappingURL=src-DG72tyzB.js.map
|