@happyvertical/smrt-app-runtime 0.53.4 → 0.53.5
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 +47 -4
- package/README.md +28 -18
- package/dist/chunks/{src-DZGOVGsU.js → src-D7cM0zHW.js} +347 -53
- package/dist/chunks/src-D7cM0zHW.js.map +1 -0
- package/dist/direct-membership.d.ts +39 -0
- package/dist/direct-membership.d.ts.map +1 -0
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/local-mcp-tokens.d.ts +123 -0
- package/dist/local-mcp-tokens.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/plain-data.d.ts +27 -0
- package/dist/plain-data.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +5 -5
- package/dist/state-root.d.ts +11 -0
- package/dist/state-root.d.ts.map +1 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/owner-setup.d.ts +10 -3
- package/dist/sveltekit/owner-setup.d.ts.map +1 -1
- package/dist/sveltekit/runtime.d.ts +99 -13
- package/dist/sveltekit/runtime.d.ts.map +1 -1
- package/dist/sveltekit.js +119 -16
- package/dist/sveltekit.js.map +1 -1
- package/package.json +9 -9
- package/dist/chunks/src-DZGOVGsU.js.map +0 -1
package/AGENTS.md
CHANGED
|
@@ -33,6 +33,30 @@ Application infrastructure composition for the validated runtime profiles in
|
|
|
33
33
|
A token that cannot claim fails a read-only preflight before role/catalog
|
|
34
34
|
seeding (#3323); the claim transaction's conditional UPDATE stays the
|
|
35
35
|
authority for single use, expiry, and concurrency.
|
|
36
|
+
- Local MCP tokens (`mcpTokens`, `openLocalMcpTokenStore`, `smrt app token`):
|
|
37
|
+
owner-bound, scoped, expiring (default 30 d, max 365 d), revocable. Only a
|
|
38
|
+
domain-separated HMAC (application secret) is stored, in the runtime-owned
|
|
39
|
+
`_smrt_local_mcp_tokens` system table (created like the bootstrap table;
|
|
40
|
+
never application schema). Issue refuses scopes the owner lacks; verify
|
|
41
|
+
returns token scopes ∩ live permissions and `null` for anything else.
|
|
42
|
+
- Bearer binding is explicit (`tenantBinding`, set by the auth adapter;
|
|
43
|
+
`direct-membership.ts`): `direct` (local tokens, default hosted resolver)
|
|
44
|
+
needs an active direct membership in exactly the tenant; the default
|
|
45
|
+
`direct-or-inherited` (hosted app mappings) applies
|
|
46
|
+
`isSessionTenantAuthorized`'s rule. The direct row, when present, is pinned
|
|
47
|
+
into `PermissionResolver`, so inheritance never substitutes for it.
|
|
48
|
+
`verify()` principals (and their `scopes` arrays) are frozen;
|
|
49
|
+
`runAsPrincipal` takes a validated, deep-frozen, null-prototype copy of
|
|
50
|
+
the principal before any await (`plain-data.ts`: own data only; poison
|
|
51
|
+
keys `__proto__`/`constructor`/`prototype`, accessors, inherited or
|
|
52
|
+
non-plain data, sparse arrays and functions rejected), authorizes from it,
|
|
53
|
+
and hands `fn` that snapshot. Token `issue()` input is read the same way.
|
|
54
|
+
They carry
|
|
55
|
+
`tenantBinding: 'direct'` and the
|
|
56
|
+
`LOCAL_MCP_TOKEN_PRINCIPAL` brand; `runAsPrincipal` rejects a copy that
|
|
57
|
+
widens the binding, so the two public APIs compose safely.
|
|
58
|
+
Never log or return the token outside `issue()`. The operator store opens an
|
|
59
|
+
uncached connection and takes no writer lease (revocation is per request).
|
|
36
60
|
- Background jobs and application-defined paid capabilities are default-off.
|
|
37
61
|
- The embedded runner reuses `TaskRunner`; it is not a second job contract.
|
|
38
62
|
|
|
@@ -73,6 +97,9 @@ Application infrastructure composition for the validated runtime profiles in
|
|
|
73
97
|
- Server-only subpath; `@sveltejs/kit` is an optional peer and is externalized
|
|
74
98
|
in `vite.config.ts` (kit recognizes `redirect`/`fail` by class identity).
|
|
75
99
|
The root entry must never import kit or Svelte.
|
|
100
|
+
- Without a `runtime` option the profile comes from smrt-config's
|
|
101
|
+
`resolveEffectiveApplicationRuntime(await loadConfig())`, the same rule as
|
|
102
|
+
`smrt app` (#3446): no block → `local`; `null`/`false`/`0`/`''` fail closed.
|
|
76
103
|
- `createSmrtSvelteKitRuntime()` order is fixed: readiness gate → URL tenant
|
|
77
104
|
selection (`locals.selectedTenant*`, never tenant context, headers ignored) →
|
|
78
105
|
verified session (tenant context entered only when
|
|
@@ -86,12 +113,28 @@ Application infrastructure composition for the validated runtime profiles in
|
|
|
86
113
|
transaction-bound request db (class `db` overrides win). Anything that
|
|
87
114
|
outlives a request (session service, tenant selector) must use the base
|
|
88
115
|
config, never these.
|
|
89
|
-
- The writer lease
|
|
90
|
-
|
|
91
|
-
`
|
|
92
|
-
|
|
116
|
+
- The local writer lease defaults to `defaultWriterLease()` (root
|
|
117
|
+
`acquireWriterLease` over `prepareApplicationStateRoot`, presenting
|
|
118
|
+
`SMRT_OPERATION_INSTANCE`); `acquireWriterLease` replaces it, `false` opts
|
|
119
|
+
out, deployed profiles never take it. Provider readiness stays injected:
|
|
120
|
+
apps pass `createProviderReadinessProbe`; deployed startup fails closed
|
|
121
|
+
without `providerReadiness`.
|
|
122
|
+
- `getCollection()` builds from `classOptions()` on every call (RLS request db
|
|
123
|
+
inside a request); each generated route's embedded prelude imports the
|
|
124
|
+
app's `smrt.ts` and reaches it through core's
|
|
125
|
+
`createGeneratedCollectionAccess()` (a legacy accessor export wins,
|
|
126
|
+
deprecated for one release).
|
|
127
|
+
- Owner setup removes the `smrt app` onboarding hand-off files by default,
|
|
128
|
+
immediately after the claim commits (before cookie serialization or hooks);
|
|
129
|
+
`onOwnerClaimed` is for additional cleanup and
|
|
130
|
+
`removeOnboardingHandoff: false` is the explicit opt-out.
|
|
93
131
|
- Owner setup re-checks loopback peer and loopback URL host per request and
|
|
94
132
|
returns only fixed `{ code, message }` failures.
|
|
133
|
+
- `verifyLocalMcpToken` (local only) and `resolveMcpPrincipal` (hosted only:
|
|
134
|
+
issuer/subject → `oidc_identities` → exactly one active user with exactly
|
|
135
|
+
one active direct membership, else `null`) are the runtime's MCP credential
|
|
136
|
+
bindings; pass the runtime as `createHostedMcpResourceAuth({ runtime })`.
|
|
137
|
+
Token claims never select a tenant.
|
|
95
138
|
- `resolveApplicationId()` / `runtimeConfigurationFingerprint()` must stay
|
|
96
139
|
byte-compatible with process managers (golden vectors in tests).
|
|
97
140
|
|
package/README.md
CHANGED
|
@@ -147,12 +147,22 @@ override), so call them per request and never retain the result. Downstream code
|
|
|
147
147
|
at most once: its error propagates unchanged, and a session-layer failure
|
|
148
148
|
before it returns 500 with no authenticated locals. Defaults read `SMRT_APP_ID`, `SMRT_DATA_DIR`, `HOST`, `DATABASE_URL`,
|
|
149
149
|
`TENANT_BASE_DOMAIN`, and `SMRT_BACKGROUND_JOBS`; `smrt.config` `runtime`
|
|
150
|
-
selects the profile (local when absent
|
|
151
|
-
|
|
152
|
-
`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
selects the profile (local when absent; a present `null`/`false`/`0`/`''`
|
|
151
|
+
fails closed, as in `smrt app`). Deployed profiles additionally require
|
|
152
|
+
`providerReadiness` probes and fail closed without them. In the local profile
|
|
153
|
+
the runtime holds the single-writer lease shared with `smrt app` operations by
|
|
154
|
+
default (`defaultWriterLease()`: `writer.lease` under
|
|
155
|
+
`prepareApplicationStateRoot()`, presenting `SMRT_OPERATION_INSTANCE`); pass
|
|
156
|
+
`acquireWriterLease` to replace it or `acquireWriterLease: false` to hold none.
|
|
157
|
+
Optional hooks: `onBootstrapInvitation` (present a newly issued setup token),
|
|
158
|
+
`selectTenant`, `session`, and `classOverrides`.
|
|
159
|
+
`runtime.getCollection(className)` returns the registered collection built
|
|
160
|
+
from `runtime.classOptions(className)` on that call. Each generated `/api/*`
|
|
161
|
+
route embeds a prelude that imports the app's `src/lib/server/smrt.ts` and
|
|
162
|
+
resolves collections through its exported `runtime` (core's
|
|
163
|
+
`createGeneratedCollectionAccess()`), so an app's `smrt.ts` is only the
|
|
164
|
+
runtime and its options. A legacy `getCollection`/`getSmrtConfig` export there
|
|
165
|
+
still takes precedence for one release, with a deprecation warning.
|
|
156
166
|
|
|
157
167
|
Mountable routes:
|
|
158
168
|
|
|
@@ -175,7 +185,12 @@ loopback; its `default` action reads `token`, `name`, `email`, and optional
|
|
|
175
185
|
`tenantName`, sets the session cookie, and redirects 303, or returns
|
|
176
186
|
`fail(status, { code, message })` with `setup_disabled` (404),
|
|
177
187
|
`setup_unavailable` (403), `setup_invalid_input` (400), or `setup_invalid`
|
|
178
|
-
(400). Claim error text is never returned.
|
|
188
|
+
(400). Claim error text is never returned. After the claim commits, the
|
|
189
|
+
runtime removes the `smrt app setup` / `recover` hand-off files
|
|
190
|
+
(`ONBOARDING_HANDOFF_FILES`: `onboarding.json`, `onboarding-launch.html`) from
|
|
191
|
+
`runtime.applicationStateRoot()`, so `pnpm app:open` stops offering the spent
|
|
192
|
+
invitation; failed claims leave them untouched. `onOwnerClaimed` remains for
|
|
193
|
+
extra app cleanup and `removeOnboardingHandoff: false` opts out.
|
|
179
194
|
|
|
180
195
|
`resolveApplicationId()` and `runtimeConfigurationFingerprint()` (root entry)
|
|
181
196
|
are the canonical app ID and secret-free configuration fingerprint shared by
|
|
@@ -184,21 +199,16 @@ the web health route and process managers.
|
|
|
184
199
|
The root entry also owns the operator state shared with `smrt app`:
|
|
185
200
|
`prepareApplicationStateRoot()` (private, app-bound state directory),
|
|
186
201
|
`withOperationLock()`, `acquireWriterLease()` / `readActiveWriterLease()`,
|
|
187
|
-
and `createProviderReadinessProbe()`.
|
|
188
|
-
|
|
202
|
+
and `createProviderReadinessProbe()`. The SvelteKit runtime takes the writer
|
|
203
|
+
lease itself; a SvelteKit app passes only the readiness probe and its own
|
|
204
|
+
options, without depending on the CLI:
|
|
189
205
|
|
|
190
206
|
```ts
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
prepareApplicationStateRoot,
|
|
195
|
-
} from '@happyvertical/smrt-app-runtime';
|
|
207
|
+
// src/lib/server/smrt.ts
|
|
208
|
+
import { createProviderReadinessProbe } from '@happyvertical/smrt-app-runtime';
|
|
209
|
+
import { createSmrtSvelteKitRuntime } from '@happyvertical/smrt-app-runtime/sveltekit';
|
|
196
210
|
|
|
197
211
|
export const runtime = createSmrtSvelteKitRuntime({
|
|
198
|
-
acquireWriterLease: (context) =>
|
|
199
|
-
acquireWriterLease(prepareApplicationStateRoot(context), {
|
|
200
|
-
operationInstance: process.env.SMRT_OPERATION_INSTANCE,
|
|
201
|
-
}),
|
|
202
212
|
providerReadiness: createProviderReadinessProbe,
|
|
203
213
|
});
|
|
204
214
|
```
|
|
@@ -9,62 +9,13 @@ import { resolveApplicationRuntime } from "@happyvertical/smrt-config";
|
|
|
9
9
|
import { ScheduleRunner, TaskRunner } from "@happyvertical/smrt-jobs";
|
|
10
10
|
import { Person, ProfileTypeCollection } from "@happyvertical/smrt-profiles";
|
|
11
11
|
import { withSystemContext } from "@happyvertical/smrt-tenancy";
|
|
12
|
-
import { DEFAULT_ROLE_SLUGS, MembershipCollection, MembershipStatus, RoleCollection, SessionCollection, SessionService, TenantService, UserCollection, UserStatus } from "@happyvertical/smrt-users";
|
|
12
|
+
import { DEFAULT_ROLE_SLUGS, MembershipCollection, MembershipStatus, PermissionResolver, RoleCollection, SessionCollection, SessionService, TenantService, UserCollection, UserStatus } from "@happyvertical/smrt-users";
|
|
13
13
|
import { getDatabase } from "@happyvertical/sql";
|
|
14
|
-
import { chmodSync, closeSync, constants as constants$1, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
15
14
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
15
|
+
import { chmodSync, closeSync, constants as constants$1, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { DatabaseSync } from "node:sqlite";
|
|
17
17
|
import { registerHooks } from "node:module";
|
|
18
18
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
-
//#region src/application-identity.ts
|
|
20
|
-
function resolveApplicationId(options = {}) {
|
|
21
|
-
if (options.explicitId) return validateApplicationId(options.explicitId);
|
|
22
|
-
let packageName = options.packageName;
|
|
23
|
-
if (packageName === void 0) {
|
|
24
|
-
const sourceRoot = options.sourceRoot ?? process.cwd();
|
|
25
|
-
packageName = JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).name;
|
|
26
|
-
}
|
|
27
|
-
if (typeof packageName !== "string" || packageName.trim() === "") throw new Error("package.json must declare a non-empty package name.");
|
|
28
|
-
return encodeApplicationId(packageName);
|
|
29
|
-
}
|
|
30
|
-
function databaseTargetIdentity(value) {
|
|
31
|
-
if (!value) return null;
|
|
32
|
-
try {
|
|
33
|
-
const target = new URL(value);
|
|
34
|
-
const sslMode = target.searchParams.get("sslmode");
|
|
35
|
-
target.username = "";
|
|
36
|
-
target.password = "";
|
|
37
|
-
target.search = "";
|
|
38
|
-
target.hash = "";
|
|
39
|
-
if (sslMode) target.searchParams.set("sslmode", sslMode);
|
|
40
|
-
return target.toString();
|
|
41
|
-
} catch {
|
|
42
|
-
return resolve(value);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
function runtimeConfigurationFingerprint(runtime, environment = process.env) {
|
|
46
|
-
return createHash("sha256").update(JSON.stringify({
|
|
47
|
-
profile: runtime.profile,
|
|
48
|
-
providers: runtime.providers,
|
|
49
|
-
databaseTarget: databaseTargetIdentity(environment.DATABASE_URL),
|
|
50
|
-
host: environment.HOST || null,
|
|
51
|
-
port: environment.PORT || null,
|
|
52
|
-
origin: environment.ORIGIN || null,
|
|
53
|
-
backgroundJobs: environment.SMRT_BACKGROUND_JOBS === "true",
|
|
54
|
-
readinessModules: {
|
|
55
|
-
authentication: environment.SMRT_AUTH_READINESS_MODULE || null,
|
|
56
|
-
assets: environment.SMRT_ASSETS_READINESS_MODULE || null,
|
|
57
|
-
secrets: environment.SMRT_SECRETS_READINESS_MODULE || null
|
|
58
|
-
},
|
|
59
|
-
mcpAuthorization: {
|
|
60
|
-
resource: environment.SMRT_MCP_RESOURCE || null,
|
|
61
|
-
issuer: environment.SMRT_MCP_ISSUER || null,
|
|
62
|
-
jwksUri: environment.SMRT_MCP_JWKS_URI || null,
|
|
63
|
-
scopes: environment.SMRT_MCP_SCOPES || null
|
|
64
|
-
}
|
|
65
|
-
})).digest("hex");
|
|
66
|
-
}
|
|
67
|
-
//#endregion
|
|
68
19
|
//#region src/deployed-runtime.ts
|
|
69
20
|
var DeployedRuntimeError = class extends Error {
|
|
70
21
|
constructor(code, message, component) {
|
|
@@ -648,6 +599,303 @@ function deepFreeze$1(value, seen = /* @__PURE__ */ new WeakSet()) {
|
|
|
648
599
|
return value;
|
|
649
600
|
}
|
|
650
601
|
//#endregion
|
|
602
|
+
//#region src/direct-membership.ts
|
|
603
|
+
async function resolveBoundMembershipPermissions(options) {
|
|
604
|
+
const { memberships, resolver, userId, tenantId, binding } = options;
|
|
605
|
+
const direct = await withSystemContext(() => memberships.findByUserAndTenant(userId, tenantId));
|
|
606
|
+
if (direct) {
|
|
607
|
+
if (direct.userId !== userId || direct.tenantId !== tenantId || !direct.isActive()) return null;
|
|
608
|
+
} else if (binding !== "direct-or-inherited") return null;
|
|
609
|
+
const resolved = await resolver.resolvePermissions(userId, tenantId, { membership: direct ?? null });
|
|
610
|
+
if (!isSessionTenantAuthorized({
|
|
611
|
+
membership: direct ?? null,
|
|
612
|
+
tenantAuthorization: {
|
|
613
|
+
membershipId: resolved.membershipId,
|
|
614
|
+
inheritedFromTenantId: resolved.inheritedFromTenantId
|
|
615
|
+
}
|
|
616
|
+
}) || !resolved.membershipId) return null;
|
|
617
|
+
return {
|
|
618
|
+
membershipId: resolved.membershipId,
|
|
619
|
+
inheritedFromTenantId: resolved.inheritedFromTenantId,
|
|
620
|
+
permissions: resolved.permissions
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/plain-data.ts
|
|
625
|
+
var FORBIDDEN_DATA_KEYS = /* @__PURE__ */ new Set([
|
|
626
|
+
"__proto__",
|
|
627
|
+
"constructor",
|
|
628
|
+
"prototype"
|
|
629
|
+
]);
|
|
630
|
+
var PlainDataError = class extends Error {
|
|
631
|
+
name = "PlainDataError";
|
|
632
|
+
};
|
|
633
|
+
function deepFrozenPlainCopy(value, depth = 0) {
|
|
634
|
+
if (typeof value === "function") throw new PlainDataError("may contain only plain data");
|
|
635
|
+
if (value === null || typeof value !== "object") return value;
|
|
636
|
+
if (depth > 8) throw new PlainDataError("is nested too deeply");
|
|
637
|
+
if (Array.isArray(value)) {
|
|
638
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) throw new PlainDataError("may contain only plain arrays");
|
|
639
|
+
const length = value.length;
|
|
640
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
641
|
+
if (key === "length") continue;
|
|
642
|
+
if (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length) throw new PlainDataError("may contain only plain arrays");
|
|
643
|
+
}
|
|
644
|
+
const copy2 = [];
|
|
645
|
+
for (let index = 0; index < length; index += 1) {
|
|
646
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, index);
|
|
647
|
+
if (!descriptor || !("value" in descriptor)) throw new PlainDataError("may contain only dense data arrays");
|
|
648
|
+
Object.defineProperty(copy2, index, {
|
|
649
|
+
value: deepFrozenPlainCopy(descriptor.value, depth + 1),
|
|
650
|
+
enumerable: true,
|
|
651
|
+
writable: false,
|
|
652
|
+
configurable: false
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return Object.freeze(copy2);
|
|
656
|
+
}
|
|
657
|
+
const prototype = Object.getPrototypeOf(value);
|
|
658
|
+
if (prototype !== Object.prototype && prototype !== null) throw new PlainDataError("may contain only plain data");
|
|
659
|
+
const copy = /* @__PURE__ */ Object.create(null);
|
|
660
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
661
|
+
if (typeof key === "string" && FORBIDDEN_DATA_KEYS.has(key)) throw new PlainDataError(`may not contain the key ${key}`);
|
|
662
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
|
|
663
|
+
if (!descriptor?.enumerable) continue;
|
|
664
|
+
if (!("value" in descriptor)) throw new PlainDataError("may not contain accessor properties");
|
|
665
|
+
Object.defineProperty(copy, key, {
|
|
666
|
+
value: deepFrozenPlainCopy(descriptor.value, depth + 1),
|
|
667
|
+
enumerable: true,
|
|
668
|
+
writable: false,
|
|
669
|
+
configurable: false
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
return Object.freeze(copy);
|
|
673
|
+
}
|
|
674
|
+
function ownValue(record, key) {
|
|
675
|
+
return Object.hasOwn(record, key) ? record[key] : void 0;
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/local-mcp-tokens.ts
|
|
679
|
+
var LOCAL_MCP_TOKEN_TABLE = "_smrt_local_mcp_tokens";
|
|
680
|
+
var LOCAL_MCP_TOKEN_PREFIX = "smrt_mcp_";
|
|
681
|
+
var DEFAULT_LOCAL_MCP_TOKEN_TTL_SECONDS = 720 * 60 * 60;
|
|
682
|
+
var MAX_LOCAL_MCP_TOKEN_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
683
|
+
var MAX_SCOPES = 64;
|
|
684
|
+
var MAX_SCOPE_LENGTH = 128;
|
|
685
|
+
var MAX_LABEL_LENGTH = 80;
|
|
686
|
+
var SCOPE_PATTERN = /^[\x21\x23-\x5b\x5d-\x7e]+$/u;
|
|
687
|
+
var TOKEN_PATTERN = /^smrt_mcp_[A-Za-z0-9_-]{43}$/u;
|
|
688
|
+
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;
|
|
689
|
+
var HMAC_DOMAIN = "smrt.local-mcp-token.v1\0";
|
|
690
|
+
var LOCAL_MCP_TOKEN_PRINCIPAL = /* @__PURE__ */ Symbol.for("@happyvertical/smrt-app-runtime/local-mcp-token-principal");
|
|
691
|
+
function isLocalMcpTokenPrincipal(value) {
|
|
692
|
+
return typeof value === "object" && value !== null && value[LOCAL_MCP_TOKEN_PRINCIPAL] === true;
|
|
693
|
+
}
|
|
694
|
+
async function ensureLocalMcpTokenTable(db) {
|
|
695
|
+
await db.query(`CREATE TABLE IF NOT EXISTS ${LOCAL_MCP_TOKEN_TABLE} (
|
|
696
|
+
id TEXT PRIMARY KEY,
|
|
697
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
698
|
+
user_id TEXT NOT NULL,
|
|
699
|
+
tenant_id TEXT NOT NULL,
|
|
700
|
+
scopes TEXT NOT NULL,
|
|
701
|
+
label TEXT NULL,
|
|
702
|
+
created_at TEXT NOT NULL,
|
|
703
|
+
expires_at TEXT NOT NULL,
|
|
704
|
+
revoked_at TEXT NULL
|
|
705
|
+
)`);
|
|
706
|
+
}
|
|
707
|
+
function createLocalMcpTokenStore(deps) {
|
|
708
|
+
const { db, now, fail } = deps;
|
|
709
|
+
let keyPromise;
|
|
710
|
+
const hashKey = () => {
|
|
711
|
+
keyPromise ??= deps.readSecret().then((secret) => createHmac("sha256", secret.trim()).update(HMAC_DOMAIN).digest()).catch((error) => {
|
|
712
|
+
keyPromise = void 0;
|
|
713
|
+
throw error;
|
|
714
|
+
});
|
|
715
|
+
return keyPromise;
|
|
716
|
+
};
|
|
717
|
+
const hashToken = async (token) => createHmac("sha256", await hashKey()).update(token).digest("hex");
|
|
718
|
+
let resolverPromise;
|
|
719
|
+
const livePermissions = async (userId, tenantId) => {
|
|
720
|
+
resolverPromise ??= PermissionResolver.create({ db }).catch((error) => {
|
|
721
|
+
resolverPromise = void 0;
|
|
722
|
+
throw error;
|
|
723
|
+
});
|
|
724
|
+
return (await resolveBoundMembershipPermissions({
|
|
725
|
+
memberships: await MembershipCollection.create({ db }),
|
|
726
|
+
resolver: await resolverPromise,
|
|
727
|
+
userId,
|
|
728
|
+
tenantId,
|
|
729
|
+
binding: "direct"
|
|
730
|
+
}))?.permissions ?? null;
|
|
731
|
+
};
|
|
732
|
+
return Object.freeze({
|
|
733
|
+
async issue(input) {
|
|
734
|
+
let fields;
|
|
735
|
+
try {
|
|
736
|
+
fields = deepFrozenPlainCopy(input ?? {});
|
|
737
|
+
} catch (error) {
|
|
738
|
+
if (!(error instanceof PlainDataError)) throw error;
|
|
739
|
+
throw fail("invalid_configuration", `MCP token input ${error.message}.`);
|
|
740
|
+
}
|
|
741
|
+
if (fields === null || typeof fields !== "object") throw fail("invalid_configuration", "MCP token input must be an object.");
|
|
742
|
+
const scopes = normalizeScopes(ownValue(fields, "scopes"), fail);
|
|
743
|
+
const ttl = ownValue(fields, "expiresInSeconds") ?? 2592e3;
|
|
744
|
+
if (!Number.isSafeInteger(ttl) || ttl < 60 || ttl > 31536e3) throw fail("invalid_configuration", "MCP token lifetime must be between one minute and 365 days.");
|
|
745
|
+
const label = normalizeLabel(ownValue(fields, "label"), fail);
|
|
746
|
+
const owner = await deps.findOwner(db);
|
|
747
|
+
if (!owner) throw fail("owner_unavailable", "No local owner exists yet; finish owner setup before issuing MCP tokens.");
|
|
748
|
+
const held = await livePermissions(owner.userId, owner.tenantId);
|
|
749
|
+
const missing = scopes.filter((scope) => !held?.has(scope));
|
|
750
|
+
if (missing.length > 0) throw fail("invalid_scope", `The local owner does not hold: ${missing.join(", ")}.`);
|
|
751
|
+
await ensureLocalMcpTokenTable(db);
|
|
752
|
+
const token = `${LOCAL_MCP_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
753
|
+
const id = randomUUID();
|
|
754
|
+
const created = now();
|
|
755
|
+
const createdAt = created.toISOString();
|
|
756
|
+
const expiresAt = new Date(created.getTime() + ttl * 1e3).toISOString();
|
|
757
|
+
await db.query(`INSERT INTO ${LOCAL_MCP_TOKEN_TABLE}
|
|
758
|
+
(id, token_hash, user_id, tenant_id, scopes, label, created_at, expires_at, revoked_at)
|
|
759
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, id, await hashToken(token), owner.userId, owner.tenantId, JSON.stringify(scopes), label, createdAt, expiresAt);
|
|
760
|
+
return Object.freeze({
|
|
761
|
+
id,
|
|
762
|
+
token,
|
|
763
|
+
scopes: Object.freeze([...scopes]),
|
|
764
|
+
label,
|
|
765
|
+
createdAt,
|
|
766
|
+
expiresAt
|
|
767
|
+
});
|
|
768
|
+
},
|
|
769
|
+
async list() {
|
|
770
|
+
await ensureLocalMcpTokenTable(db);
|
|
771
|
+
const result = await db.query(`SELECT id, scopes, label, created_at, expires_at, revoked_at
|
|
772
|
+
FROM ${LOCAL_MCP_TOKEN_TABLE}
|
|
773
|
+
ORDER BY created_at DESC, id`);
|
|
774
|
+
const current = now().getTime();
|
|
775
|
+
return result.rows.map((row) => {
|
|
776
|
+
const expiresAt = String(row.expires_at);
|
|
777
|
+
const revokedAt = row.revoked_at == null ? null : String(row.revoked_at);
|
|
778
|
+
const status = revokedAt ? "revoked" : Date.parse(expiresAt) > current ? "active" : "expired";
|
|
779
|
+
return Object.freeze({
|
|
780
|
+
id: String(row.id),
|
|
781
|
+
scopes: Object.freeze(parseScopes(row.scopes) ?? []),
|
|
782
|
+
label: row.label == null ? null : String(row.label),
|
|
783
|
+
createdAt: String(row.created_at),
|
|
784
|
+
expiresAt,
|
|
785
|
+
revokedAt,
|
|
786
|
+
status
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
},
|
|
790
|
+
async revoke(id) {
|
|
791
|
+
if (typeof id !== "string" || !TOKEN_ID_PATTERN.test(id)) throw fail("invalid_configuration", "Unknown MCP token id.");
|
|
792
|
+
await ensureLocalMcpTokenTable(db);
|
|
793
|
+
if ((await db.query(`UPDATE _smrt_local_mcp_tokens
|
|
794
|
+
SET revoked_at = ?
|
|
795
|
+
WHERE id = ? AND revoked_at IS NULL
|
|
796
|
+
RETURNING id`, now().toISOString(), id)).rows.length > 0) return "revoked";
|
|
797
|
+
if ((await db.query(`SELECT id FROM _smrt_local_mcp_tokens WHERE id = ? LIMIT 1`, id)).rows.length === 0) throw fail("invalid_configuration", "Unknown MCP token id.");
|
|
798
|
+
return "already-revoked";
|
|
799
|
+
},
|
|
800
|
+
async verify(token) {
|
|
801
|
+
if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) return null;
|
|
802
|
+
const row = (await db.query(`SELECT id, user_id, tenant_id, scopes, expires_at, revoked_at
|
|
803
|
+
FROM ${LOCAL_MCP_TOKEN_TABLE}
|
|
804
|
+
WHERE token_hash = ?
|
|
805
|
+
LIMIT 1`, await hashToken(token))).rows[0];
|
|
806
|
+
if (!row || row.revoked_at != null) return null;
|
|
807
|
+
const expiresAt = Date.parse(String(row.expires_at));
|
|
808
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now().getTime()) return null;
|
|
809
|
+
const userId = row.user_id;
|
|
810
|
+
const tenantId = row.tenant_id;
|
|
811
|
+
const scopes = parseScopes(row.scopes);
|
|
812
|
+
if (typeof userId !== "string" || userId.length === 0 || typeof tenantId !== "string" || tenantId.length === 0 || !scopes) return null;
|
|
813
|
+
const user = await (await UserCollection.create({ db })).get({ id: userId });
|
|
814
|
+
if (!user || user.status !== UserStatus.ACTIVE) return null;
|
|
815
|
+
const held = await livePermissions(userId, tenantId);
|
|
816
|
+
if (!held) return null;
|
|
817
|
+
return Object.freeze({
|
|
818
|
+
id: userId,
|
|
819
|
+
tenantId,
|
|
820
|
+
kind: "human",
|
|
821
|
+
scopes: Object.freeze(scopes.filter((scope) => held.has(scope))),
|
|
822
|
+
tenantBinding: "direct",
|
|
823
|
+
[LOCAL_MCP_TOKEN_PRINCIPAL]: true
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
function normalizeScopes(value, fail) {
|
|
829
|
+
if (!Array.isArray(value) || value.length === 0) throw fail("invalid_scope", "An MCP token needs at least one scope.");
|
|
830
|
+
if (value.length > MAX_SCOPES) throw fail("invalid_scope", "An MCP token may carry at most 64 scopes.");
|
|
831
|
+
for (const scope of value) if (typeof scope !== "string" || scope.length > MAX_SCOPE_LENGTH || !SCOPE_PATTERN.test(scope)) throw fail("invalid_scope", "Invalid MCP token scope.");
|
|
832
|
+
return [...new Set(value)].sort();
|
|
833
|
+
}
|
|
834
|
+
function normalizeLabel(value, fail) {
|
|
835
|
+
if (value === void 0) return null;
|
|
836
|
+
const label = typeof value === "string" ? value.trim() : "";
|
|
837
|
+
if (label.length === 0 || label.length > MAX_LABEL_LENGTH || /[\u0000-\u001f\u007f]/u.test(label)) throw fail("invalid_configuration", "An MCP token label must be 1-80 printable characters.");
|
|
838
|
+
return label;
|
|
839
|
+
}
|
|
840
|
+
function parseScopes(value) {
|
|
841
|
+
if (typeof value !== "string") return null;
|
|
842
|
+
try {
|
|
843
|
+
const parsed = JSON.parse(value);
|
|
844
|
+
return Array.isArray(parsed) && parsed.every((scope) => typeof scope === "string") ? parsed : null;
|
|
845
|
+
} catch {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/application-identity.ts
|
|
851
|
+
function resolveApplicationId(options = {}) {
|
|
852
|
+
if (options.explicitId) return validateApplicationId(options.explicitId);
|
|
853
|
+
let packageName = options.packageName;
|
|
854
|
+
if (packageName === void 0) {
|
|
855
|
+
const sourceRoot = options.sourceRoot ?? process.cwd();
|
|
856
|
+
packageName = JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).name;
|
|
857
|
+
}
|
|
858
|
+
if (typeof packageName !== "string" || packageName.trim() === "") throw new Error("package.json must declare a non-empty package name.");
|
|
859
|
+
return encodeApplicationId(packageName);
|
|
860
|
+
}
|
|
861
|
+
function databaseTargetIdentity(value) {
|
|
862
|
+
if (!value) return null;
|
|
863
|
+
try {
|
|
864
|
+
const target = new URL(value);
|
|
865
|
+
const sslMode = target.searchParams.get("sslmode");
|
|
866
|
+
target.username = "";
|
|
867
|
+
target.password = "";
|
|
868
|
+
target.search = "";
|
|
869
|
+
target.hash = "";
|
|
870
|
+
if (sslMode) target.searchParams.set("sslmode", sslMode);
|
|
871
|
+
return target.toString();
|
|
872
|
+
} catch {
|
|
873
|
+
return resolve(value);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
function runtimeConfigurationFingerprint(runtime, environment = process.env) {
|
|
877
|
+
return createHash("sha256").update(JSON.stringify({
|
|
878
|
+
profile: runtime.profile,
|
|
879
|
+
providers: runtime.providers,
|
|
880
|
+
databaseTarget: databaseTargetIdentity(environment.DATABASE_URL),
|
|
881
|
+
host: environment.HOST || null,
|
|
882
|
+
port: environment.PORT || null,
|
|
883
|
+
origin: environment.ORIGIN || null,
|
|
884
|
+
backgroundJobs: environment.SMRT_BACKGROUND_JOBS === "true",
|
|
885
|
+
readinessModules: {
|
|
886
|
+
authentication: environment.SMRT_AUTH_READINESS_MODULE || null,
|
|
887
|
+
assets: environment.SMRT_ASSETS_READINESS_MODULE || null,
|
|
888
|
+
secrets: environment.SMRT_SECRETS_READINESS_MODULE || null
|
|
889
|
+
},
|
|
890
|
+
mcpAuthorization: {
|
|
891
|
+
resource: environment.SMRT_MCP_RESOURCE || null,
|
|
892
|
+
issuer: environment.SMRT_MCP_ISSUER || null,
|
|
893
|
+
jwksUri: environment.SMRT_MCP_JWKS_URI || null,
|
|
894
|
+
scopes: environment.SMRT_MCP_SCOPES || null
|
|
895
|
+
}
|
|
896
|
+
})).digest("hex");
|
|
897
|
+
}
|
|
898
|
+
//#endregion
|
|
651
899
|
//#region src/error-code.ts
|
|
652
900
|
function errorCode(error) {
|
|
653
901
|
return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
@@ -1132,6 +1380,10 @@ function prepareApplicationStateRoot(options) {
|
|
|
1132
1380
|
}
|
|
1133
1381
|
return stateRoot;
|
|
1134
1382
|
}
|
|
1383
|
+
var ONBOARDING_HANDOFF_FILES = Object.freeze(["onboarding.json", "onboarding-launch.html"]);
|
|
1384
|
+
function removeOnboardingHandoff(stateRoot) {
|
|
1385
|
+
for (const file of ONBOARDING_HANDOFF_FILES) rmSync(join(stateRoot, file), { force: true });
|
|
1386
|
+
}
|
|
1135
1387
|
//#endregion
|
|
1136
1388
|
//#region src/writer-lease.ts
|
|
1137
1389
|
function validateRecord(record) {
|
|
@@ -1345,6 +1597,45 @@ async function validateLocalDatabaseStorage(options) {
|
|
|
1345
1597
|
if (database.isSymbolicLink() || !database.isFile() || currentUid !== void 0 && database.uid !== currentUid || (database.mode & 511) !== 384) throw unsafeFilesystemEntry(`Local application database must be a current-user-owned mode-0600 file: ${paths.database}`);
|
|
1346
1598
|
return paths;
|
|
1347
1599
|
}
|
|
1600
|
+
async function openLocalMcpTokenStore(options) {
|
|
1601
|
+
const paths = await validateLocalDatabaseStorage(options);
|
|
1602
|
+
const canonicalSourceRoot = await realpath(resolve(options.sourceRoot ?? process.cwd()));
|
|
1603
|
+
const db = await getDatabase({
|
|
1604
|
+
type: "sqlite",
|
|
1605
|
+
url: paths.database,
|
|
1606
|
+
cache: false,
|
|
1607
|
+
secureFile: {
|
|
1608
|
+
driver: "node:sqlite",
|
|
1609
|
+
custody: "trusted-parent",
|
|
1610
|
+
root: paths.root
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
try {
|
|
1614
|
+
await tuneLocalSqlite(db);
|
|
1615
|
+
await ensureLocalMcpTokenTable(db);
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
throw await closeDatabaseAfterFailure(db, error);
|
|
1618
|
+
}
|
|
1619
|
+
const store = localMcpTokenStore(db, paths, canonicalSourceRoot, options.now ?? (() => /* @__PURE__ */ new Date()));
|
|
1620
|
+
return Object.freeze({
|
|
1621
|
+
issue: store.issue,
|
|
1622
|
+
list: store.list,
|
|
1623
|
+
revoke: store.revoke,
|
|
1624
|
+
verify: store.verify,
|
|
1625
|
+
close: async () => {
|
|
1626
|
+
await db.close?.();
|
|
1627
|
+
}
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
function localMcpTokenStore(db, paths, canonicalSourceRoot, now) {
|
|
1631
|
+
return createLocalMcpTokenStore({
|
|
1632
|
+
db,
|
|
1633
|
+
now,
|
|
1634
|
+
readSecret: () => validateApplicationSecret(paths.applicationSecret, canonicalSourceRoot),
|
|
1635
|
+
findOwner: findExistingOwner,
|
|
1636
|
+
fail: (code, message) => new LocalRuntimeError(code, message)
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1348
1639
|
var MIGRATION_FAILED_MESSAGE = "The application migration step failed; run pnpm app:setup and inspect the private migration logs.";
|
|
1349
1640
|
var LocalRuntimeError = class extends Error {
|
|
1350
1641
|
constructor(code, message) {
|
|
@@ -1400,6 +1691,7 @@ async function initializeLocalApplicationRuntime(options) {
|
|
|
1400
1691
|
throw migrationFailed(migrationFailure);
|
|
1401
1692
|
}
|
|
1402
1693
|
await ensureBootstrapTable(db);
|
|
1694
|
+
await ensureLocalMcpTokenTable(db);
|
|
1403
1695
|
const runtime = new InitializedLocalApplicationRuntime({
|
|
1404
1696
|
db,
|
|
1405
1697
|
paths,
|
|
@@ -1446,6 +1738,7 @@ var InitializedLocalApplicationRuntime = class {
|
|
|
1446
1738
|
resolvedRuntime;
|
|
1447
1739
|
backgroundJobsEnabled;
|
|
1448
1740
|
paidCapabilitiesEnabled;
|
|
1741
|
+
mcpTokens;
|
|
1449
1742
|
bootstrapTtlSeconds;
|
|
1450
1743
|
sessionTtlSeconds;
|
|
1451
1744
|
now;
|
|
@@ -1461,6 +1754,7 @@ var InitializedLocalApplicationRuntime = class {
|
|
|
1461
1754
|
this.paidCapabilitiesEnabled = state.paidCapabilities;
|
|
1462
1755
|
this.now = state.now;
|
|
1463
1756
|
this.canonicalSourceRoot = state.canonicalSourceRoot;
|
|
1757
|
+
this.mcpTokens = localMcpTokenStore(state.db, state.paths, state.canonicalSourceRoot, state.now);
|
|
1464
1758
|
}
|
|
1465
1759
|
/**
|
|
1466
1760
|
* Issue the first short-lived owner invitation when none is active.
|
|
@@ -2253,6 +2547,6 @@ function isDirectoryNotEmpty(error) {
|
|
|
2253
2547
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOTEMPTY";
|
|
2254
2548
|
}
|
|
2255
2549
|
//#endregion
|
|
2256
|
-
export {
|
|
2550
|
+
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 };
|
|
2257
2551
|
|
|
2258
|
-
//# sourceMappingURL=src-
|
|
2552
|
+
//# sourceMappingURL=src-D7cM0zHW.js.map
|