@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 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, provider readiness, and onboarding file cleanup are
90
- injected hooks. Apps pass the root entry's `acquireWriterLease` (over
91
- `prepareApplicationStateRoot`) and `createProviderReadinessProbe`; deployed
92
- startup fails closed without `providerReadiness`.
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). Deployed profiles additionally require
151
- `providerReadiness` probes and fail closed without them. Optional hooks:
152
- `acquireWriterLease` (local single-writer lease), `onBootstrapInvitation`
153
- (present a newly issued setup token), `selectTenant`, `session`, and
154
- `classOverrides`. `runtime.classOptions(className)` returns collection options
155
- for application code.
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()`. A SvelteKit app wires the last two into
188
- its runtime without depending on the CLI:
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
- import {
192
- acquireWriterLease,
193
- createProviderReadinessProbe,
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 { initializeDeployedApplicationRuntime as A, lockOwnerPid as C, reclaimStaleRecord as D, RECLAIM_MUTEX_FILE as E, resolveApplicationId as M, runtimeConfigurationFingerprint as N, DeployedRuntimeCleanupError as O, isStaleOperationLock as S, withOperationLock as T, projectRuntimeDiagnostics as _, prepareLocalDatabaseStorage as a, findInstalledPackage as b, validateLocalDatabaseStorage as c, prepareApplicationStateRoot as d, resolveApplicationStateRoot as f, RUNTIME_WORKER_FRESHNESS_SECONDS as g, RUNTIME_DIAGNOSTICS_SCHEMA_VERSION as h, initializeLocalApplicationRuntime as i, isSessionTenantAuthorized as j, DeployedRuntimeError as k, acquireWriterLease as l, MAX_RUNTIME_DIAGNOSTIC_TOOLS as m, MIGRATION_FAILED_MESSAGE as n, resolveLocalRuntimePaths as o, MAX_RUNTIME_DIAGNOSTIC_ERRORS as p, encodeApplicationId as r, validateApplicationId as s, LocalRuntimeError as t, readActiveWriterLease as u, PROVIDER_READINESS_SETTINGS as v, processExists as w, resolveReadinessModule as x, createProviderReadinessProbe as y };
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-DZGOVGsU.js.map
2552
+ //# sourceMappingURL=src-D7cM0zHW.js.map