@objectstack/runtime 12.6.0 → 13.0.0

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/dist/index.d.cts CHANGED
@@ -122,15 +122,15 @@ interface StandaloneStackResult {
122
122
  * App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`)
123
123
  * can wire it without a host `objectstack.config.ts`. In particular the
124
124
  * `serve` command reads `permissions[]` to honour an app-declared default
125
- * profile (ADR-0056 D7 — `appDefaultProfileName` → SecurityPlugin
126
- * `fallbackPermissionSet`) and reads both `roles[]` and `permissions[]` to
125
+ * profile (ADR-0056 D7 — `appDefaultPermissionSetName` → SecurityPlugin
126
+ * `fallbackPermissionSet`) and reads both `positions[]` and `permissions[]` to
127
127
  * register application org roles with Better-Auth. Without these the
128
128
  * artifact-serve path silently fell back to the built-in `member_default`
129
129
  * (owner-only), so an `isDefault` profile declared purely in app metadata
130
130
  * was ignored under `objectstack dev`.
131
131
  */
132
132
  permissions?: any[];
133
- roles?: any[];
133
+ positions?: any[];
134
134
  }
135
135
  declare function createStandaloneStack(config?: StandaloneStackConfig): Promise<StandaloneStackResult>;
136
136
 
@@ -1038,9 +1038,10 @@ declare class HttpDispatcher {
1038
1038
  * Handle an MCP request over the Streamable HTTP transport (`/mcp`).
1039
1039
  *
1040
1040
  * Gating + auth (fail-closed):
1041
- * - **opt-in**: only served when `OS_MCP_SERVER_ENABLED=true` (single-env
1042
- * runtime). Multi-tenant cloud overrides this gate per env. When off we
1043
- * return 404 so the surface isn't advertised.
1041
+ * - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env
1042
+ * runtime; MCP is a core platform capability). Multi-tenant cloud
1043
+ * overrides this gate per env. When opted out we return 404 so the
1044
+ * surface isn't advertised.
1044
1045
  * - **auth**: requires a principal already resolved by
1045
1046
  * `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a
1046
1047
  * session). Anonymous → 401.
@@ -1051,8 +1052,19 @@ declare class HttpDispatcher {
1051
1052
  * uses. An external agent can never exceed the key's authority.
1052
1053
  */
1053
1054
  handleMcp(body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
1054
- /** Whether the MCP HTTP surface is opted in for this single-env runtime. */
1055
+ /**
1056
+ * Whether the MCP HTTP surface is on for this single-env runtime.
1057
+ * Default-on core capability; `OS_MCP_SERVER_ENABLED=false` opts out
1058
+ * (single decision point: `isMcpServerEnabled` in `@objectstack/types`).
1059
+ */
1055
1060
  private static isMcpEnabled;
1061
+ /**
1062
+ * Absolute URL of the RFC 9728 protected-resource metadata for the MCP
1063
+ * endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the
1064
+ * OAuth track is off — the auth service owns the decision (AS enabled +
1065
+ * OAuth 2.1 TLS rule), the dispatcher only relays it. Never throws.
1066
+ */
1067
+ private getMcpResourceMetadataUrl;
1056
1068
  /**
1057
1069
  * Normalise the inbound request into a Web-standard `Request` for the MCP
1058
1070
  * transport. Accepts an already-Web `Request`, or a node/Hono-style req
package/dist/index.d.ts CHANGED
@@ -122,15 +122,15 @@ interface StandaloneStackResult {
122
122
  * App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`)
123
123
  * can wire it without a host `objectstack.config.ts`. In particular the
124
124
  * `serve` command reads `permissions[]` to honour an app-declared default
125
- * profile (ADR-0056 D7 — `appDefaultProfileName` → SecurityPlugin
126
- * `fallbackPermissionSet`) and reads both `roles[]` and `permissions[]` to
125
+ * profile (ADR-0056 D7 — `appDefaultPermissionSetName` → SecurityPlugin
126
+ * `fallbackPermissionSet`) and reads both `positions[]` and `permissions[]` to
127
127
  * register application org roles with Better-Auth. Without these the
128
128
  * artifact-serve path silently fell back to the built-in `member_default`
129
129
  * (owner-only), so an `isDefault` profile declared purely in app metadata
130
130
  * was ignored under `objectstack dev`.
131
131
  */
132
132
  permissions?: any[];
133
- roles?: any[];
133
+ positions?: any[];
134
134
  }
135
135
  declare function createStandaloneStack(config?: StandaloneStackConfig): Promise<StandaloneStackResult>;
136
136
 
@@ -1038,9 +1038,10 @@ declare class HttpDispatcher {
1038
1038
  * Handle an MCP request over the Streamable HTTP transport (`/mcp`).
1039
1039
  *
1040
1040
  * Gating + auth (fail-closed):
1041
- * - **opt-in**: only served when `OS_MCP_SERVER_ENABLED=true` (single-env
1042
- * runtime). Multi-tenant cloud overrides this gate per env. When off we
1043
- * return 404 so the surface isn't advertised.
1041
+ * - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env
1042
+ * runtime; MCP is a core platform capability). Multi-tenant cloud
1043
+ * overrides this gate per env. When opted out we return 404 so the
1044
+ * surface isn't advertised.
1044
1045
  * - **auth**: requires a principal already resolved by
1045
1046
  * `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a
1046
1047
  * session). Anonymous → 401.
@@ -1051,8 +1052,19 @@ declare class HttpDispatcher {
1051
1052
  * uses. An external agent can never exceed the key's authority.
1052
1053
  */
1053
1054
  handleMcp(body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult>;
1054
- /** Whether the MCP HTTP surface is opted in for this single-env runtime. */
1055
+ /**
1056
+ * Whether the MCP HTTP surface is on for this single-env runtime.
1057
+ * Default-on core capability; `OS_MCP_SERVER_ENABLED=false` opts out
1058
+ * (single decision point: `isMcpServerEnabled` in `@objectstack/types`).
1059
+ */
1055
1060
  private static isMcpEnabled;
1061
+ /**
1062
+ * Absolute URL of the RFC 9728 protected-resource metadata for the MCP
1063
+ * endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the
1064
+ * OAuth track is off — the auth service owns the decision (AS enabled +
1065
+ * OAuth 2.1 TLS rule), the dispatcher only relays it. Never throws.
1066
+ */
1067
+ private getMcpResourceMetadataUrl;
1056
1068
  /**
1057
1069
  * Normalise the inbound request into a Web-standard `Request` for the MCP
1058
1070
  * transport. Accepts an already-Web `Request`, or a node/Hono-style req
package/dist/index.js CHANGED
@@ -1030,7 +1030,7 @@ var init_app_plugin = __esm({
1030
1030
  if (typeof metadata?.registerInMemory === "function") {
1031
1031
  const securityBundle = this.bundle.manifest ? { ...this.bundle.manifest, ...this.bundle } : this.bundle;
1032
1032
  const SECURITY_FIELDS = [
1033
- ["roles", "role"],
1033
+ ["positions", "position"],
1034
1034
  ["permissions", "permission"],
1035
1035
  ["sharingRules", "sharing_rule"],
1036
1036
  ["policies", "policy"]
@@ -1396,8 +1396,7 @@ var init_app_plugin = __esm({
1396
1396
  "skills",
1397
1397
  "actions",
1398
1398
  "permissions",
1399
- "roles",
1400
- "profiles",
1399
+ "positions",
1401
1400
  "translations",
1402
1401
  "sharingRules",
1403
1402
  "ragPipelines",
@@ -1757,7 +1756,7 @@ async function createStandaloneStack(config) {
1757
1756
  const objects = Array.isArray(artifactBundle?.objects) ? artifactBundle.objects : void 0;
1758
1757
  const manifest = artifactBundle?.manifest;
1759
1758
  const permissions = Array.isArray(artifactBundle?.permissions) ? artifactBundle.permissions : void 0;
1760
- const roles = Array.isArray(artifactBundle?.roles) ? artifactBundle.roles : void 0;
1759
+ const positions = Array.isArray(artifactBundle?.positions) ? artifactBundle.positions : void 0;
1761
1760
  return {
1762
1761
  plugins,
1763
1762
  api: {
@@ -1768,7 +1767,7 @@ async function createStandaloneStack(config) {
1768
1767
  ...objects ? { objects } : {},
1769
1768
  ...manifest ? { manifest } : {},
1770
1769
  ...permissions ? { permissions } : {},
1771
- ...roles ? { roles } : {}
1770
+ ...positions ? { positions } : {}
1772
1771
  };
1773
1772
  }
1774
1773
  var StandaloneStackConfigSchema;
@@ -2085,7 +2084,9 @@ function safeGet(ctx, name) {
2085
2084
  // src/http-dispatcher.ts
2086
2085
  init_package_state_store();
2087
2086
  import { getEnv, resolveLocale, evaluateAuthGate, isAuthGateAllowlisted } from "@objectstack/core";
2087
+ import { isMcpServerEnabled } from "@objectstack/types";
2088
2088
  import { CoreServiceName } from "@objectstack/spec/system";
2089
+ import { MCP_OAUTH_SCOPES } from "@objectstack/spec/ai";
2089
2090
  import { pluralToSingular, PLURAL_TO_SINGULAR } from "@objectstack/spec/shared";
2090
2091
 
2091
2092
  // src/api-exposure.ts
@@ -2122,6 +2123,12 @@ import {
2122
2123
  resolveAuthzContext,
2123
2124
  resolveLocalizationContext
2124
2125
  } from "@objectstack/core";
2126
+ function extractJwtBearer(headers) {
2127
+ const auth = headers.get("authorization");
2128
+ const bearer = auth?.match(/^Bearer\s+(\S+)$/i)?.[1];
2129
+ if (!bearer || bearer.startsWith("osk_")) return void 0;
2130
+ return bearer.split(".").length === 3 ? bearer : void 0;
2131
+ }
2125
2132
  function toHeaders(input) {
2126
2133
  if (!input) return new Headers();
2127
2134
  if (typeof Headers !== "undefined" && input instanceof Headers) return input;
@@ -2140,6 +2147,22 @@ function toHeaders(input) {
2140
2147
  async function resolveExecutionContext(opts) {
2141
2148
  const headers = toHeaders(opts.request?.headers);
2142
2149
  const ql = await opts.getQl();
2150
+ let oauthPrincipal;
2151
+ let oauthBearerPresented = false;
2152
+ if (opts.acceptOAuthAccessToken) {
2153
+ const jwtBearer = extractJwtBearer(headers);
2154
+ if (jwtBearer) {
2155
+ oauthBearerPresented = true;
2156
+ try {
2157
+ const authService = await opts.getService("auth");
2158
+ const verified = await authService?.verifyMcpAccessToken?.(jwtBearer);
2159
+ if (verified?.userId && Array.isArray(verified.scopes)) {
2160
+ oauthPrincipal = verified;
2161
+ }
2162
+ } catch {
2163
+ }
2164
+ }
2165
+ }
2143
2166
  const getSession = async (h) => {
2144
2167
  try {
2145
2168
  const authService = await opts.getService("auth");
@@ -2150,19 +2173,29 @@ async function resolveExecutionContext(opts) {
2150
2173
  return void 0;
2151
2174
  }
2152
2175
  };
2153
- const authz = await resolveAuthzContext({ ql, headers, getSession });
2176
+ const getSessionForProvenance = oauthPrincipal ? async () => ({ user: { id: oauthPrincipal.userId } }) : oauthBearerPresented ? async () => void 0 : getSession;
2177
+ const authz = await resolveAuthzContext({ ql, headers, getSession: getSessionForProvenance });
2154
2178
  const ctx = {
2155
- roles: authz.roles,
2179
+ positions: authz.positions,
2156
2180
  permissions: authz.permissions,
2157
2181
  systemPermissions: authz.systemPermissions,
2158
2182
  isSystem: false
2159
2183
  };
2184
+ if (authz.userId) {
2185
+ ctx.principalKind = "human";
2186
+ } else {
2187
+ ctx.principalKind = "guest";
2188
+ ctx.positions = ["guest"];
2189
+ }
2160
2190
  if (authz.userId) ctx.userId = authz.userId;
2161
2191
  if (authz.tenantId) ctx.tenantId = authz.tenantId;
2162
2192
  if (authz.email) ctx.email = authz.email;
2163
2193
  if (authz.accessToken) ctx.accessToken = authz.accessToken;
2164
2194
  if (authz.tabPermissions) ctx.tabPermissions = authz.tabPermissions;
2165
2195
  ctx.org_user_ids = authz.org_user_ids;
2196
+ if (oauthPrincipal && ctx.userId === oauthPrincipal.userId) {
2197
+ ctx.oauthScopes = oauthPrincipal.scopes;
2198
+ }
2166
2199
  if (authz.userId) {
2167
2200
  const settings = await Promise.resolve(opts.getService("settings")).catch(() => void 0);
2168
2201
  const localization = await resolveLocalizationContext({
@@ -2414,9 +2447,10 @@ var _HttpDispatcher = class _HttpDispatcher {
2414
2447
  * Handle an MCP request over the Streamable HTTP transport (`/mcp`).
2415
2448
  *
2416
2449
  * Gating + auth (fail-closed):
2417
- * - **opt-in**: only served when `OS_MCP_SERVER_ENABLED=true` (single-env
2418
- * runtime). Multi-tenant cloud overrides this gate per env. When off we
2419
- * return 404 so the surface isn't advertised.
2450
+ * - **default-on**: served unless `OS_MCP_SERVER_ENABLED=false` (single-env
2451
+ * runtime; MCP is a core platform capability). Multi-tenant cloud
2452
+ * overrides this gate per env. When opted out we return 404 so the
2453
+ * surface isn't advertised.
2420
2454
  * - **auth**: requires a principal already resolved by
2421
2455
  * `resolveExecutionContext` (the `sys_api_key` Bearer/header path or a
2422
2456
  * session). Anonymous → 401.
@@ -2436,7 +2470,29 @@ var _HttpDispatcher = class _HttpDispatcher {
2436
2470
  }
2437
2471
  const ec = context.executionContext;
2438
2472
  if (!ec || !ec.userId && !ec.isSystem) {
2439
- return { handled: true, response: this.error("Unauthorized: a valid API key is required", 401) };
2473
+ const resourceMetadataUrl = await this.getMcpResourceMetadataUrl(context);
2474
+ const response = this.error(
2475
+ resourceMetadataUrl ? "Unauthorized: a valid OAuth access token or API key is required" : "Unauthorized: a valid API key is required",
2476
+ 401
2477
+ );
2478
+ if (resourceMetadataUrl) {
2479
+ response.headers = {
2480
+ "WWW-Authenticate": `Bearer realm="ObjectStack MCP", resource_metadata="${resourceMetadataUrl}"`
2481
+ };
2482
+ }
2483
+ return { handled: true, response };
2484
+ }
2485
+ const grantedScopes = Array.isArray(ec.oauthScopes) ? ec.oauthScopes : void 0;
2486
+ if (grantedScopes && !grantedScopes.some((s) => MCP_OAUTH_SCOPES.includes(s))) {
2487
+ const resourceMetadataUrl = await this.getMcpResourceMetadataUrl(context);
2488
+ const response = this.error(
2489
+ `Forbidden: the access token grants none of the MCP scopes (${MCP_OAUTH_SCOPES.join(", ")})`,
2490
+ 403
2491
+ );
2492
+ response.headers = {
2493
+ "WWW-Authenticate": `Bearer error="insufficient_scope", scope="${MCP_OAUTH_SCOPES.join(" ")}"` + (resourceMetadataUrl ? `, resource_metadata="${resourceMetadataUrl}"` : "")
2494
+ };
2495
+ return { handled: true, response };
2440
2496
  }
2441
2497
  const webRequest = this.toMcpWebRequest(context.request, body);
2442
2498
  if (!webRequest) {
@@ -2445,7 +2501,13 @@ var _HttpDispatcher = class _HttpDispatcher {
2445
2501
  const bridge = this.buildMcpBridge(context);
2446
2502
  let webRes;
2447
2503
  try {
2448
- webRes = await mcp.handleHttpRequest(webRequest, { bridge, parsedBody: body });
2504
+ webRes = await mcp.handleHttpRequest(webRequest, {
2505
+ bridge,
2506
+ parsedBody: body,
2507
+ // undefined = not scope-limited (API key / session); an array
2508
+ // narrows the registered tool families inside the MCP runtime.
2509
+ ...grantedScopes ? { toolOptions: { grantedScopes } } : {}
2510
+ });
2449
2511
  } catch (err) {
2450
2512
  return { handled: true, response: this.error(err?.message ?? "MCP request failed", 500) };
2451
2513
  }
@@ -2472,9 +2534,28 @@ var _HttpDispatcher = class _HttpDispatcher {
2472
2534
  }
2473
2535
  return { handled: true, response: { status: webRes.status, headers, body: responseBody } };
2474
2536
  }
2475
- /** Whether the MCP HTTP surface is opted in for this single-env runtime. */
2537
+ /**
2538
+ * Whether the MCP HTTP surface is on for this single-env runtime.
2539
+ * Default-on core capability; `OS_MCP_SERVER_ENABLED=false` opts out
2540
+ * (single decision point: `isMcpServerEnabled` in `@objectstack/types`).
2541
+ */
2476
2542
  static isMcpEnabled() {
2477
- return typeof process !== "undefined" && process.env?.OS_MCP_SERVER_ENABLED === "true";
2543
+ return isMcpServerEnabled();
2544
+ }
2545
+ /**
2546
+ * Absolute URL of the RFC 9728 protected-resource metadata for the MCP
2547
+ * endpoint, advertised via `WWW-Authenticate` (#2698). `null` when the
2548
+ * OAuth track is off — the auth service owns the decision (AS enabled +
2549
+ * OAuth 2.1 TLS rule), the dispatcher only relays it. Never throws.
2550
+ */
2551
+ async getMcpResourceMetadataUrl(context) {
2552
+ try {
2553
+ const authService = await this.resolveService("auth", context.environmentId);
2554
+ const url = authService?.getMcpResourceMetadataUrl?.();
2555
+ return typeof url === "string" && url ? url : null;
2556
+ } catch {
2557
+ return null;
2558
+ }
2478
2559
  }
2479
2560
  /**
2480
2561
  * Normalise the inbound request into a Web-standard `Request` for the MCP
@@ -3132,9 +3213,9 @@ var _HttpDispatcher = class _HttpDispatcher {
3132
3213
  notifications: hasNotification ? `${prefix}/notifications` : void 0,
3133
3214
  ai: hasAi ? `${prefix}/ai` : void 0,
3134
3215
  i18n: hasI18n ? `${prefix}/i18n` : void 0,
3135
- // MCP (Streamable HTTP) is opt-in per env only advertised
3136
- // when OS_MCP_SERVER_ENABLED=true so the surface isn't exposed
3137
- // by default. The objectui Integrations page reads this.
3216
+ // MCP (Streamable HTTP) is a default-on core capability
3217
+ // advertised unless OS_MCP_SERVER_ENABLED=false opts the env
3218
+ // out. The objectui Integrations page reads this.
3138
3219
  mcp: _HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : void 0
3139
3220
  };
3140
3221
  const svcAvailable = (route, provider) => ({
@@ -4424,7 +4505,7 @@ var _HttpDispatcher = class _HttpDispatcher {
4424
4505
  const ec = context?.executionContext;
4425
4506
  const userIdFromAuth = context?.user?.id ?? context?.userId ?? ec?.userId;
4426
4507
  if (userIdFromAuth) automationContext.userId = userIdFromAuth;
4427
- if (Array.isArray(ec?.roles) && ec.roles.length) automationContext.roles = ec.roles;
4508
+ if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions;
4428
4509
  if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions;
4429
4510
  if (ec?.tenantId) automationContext.tenantId = ec.tenantId;
4430
4511
  const result = await automationService.execute(name, automationContext);
@@ -4732,7 +4813,7 @@ var _HttpDispatcher = class _HttpDispatcher {
4732
4813
  id: ec.userId,
4733
4814
  displayName: ec.userDisplayName ?? ec.userName ?? ec.userId,
4734
4815
  email: ec.userEmail,
4735
- roles: Array.isArray(ec.roles) ? ec.roles : [],
4816
+ roles: Array.isArray(ec.positions) ? ec.positions : [],
4736
4817
  permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
4737
4818
  organizationId: ec.tenantId
4738
4819
  } : void 0;
@@ -4796,7 +4877,7 @@ var _HttpDispatcher = class _HttpDispatcher {
4796
4877
  if (!svc) {
4797
4878
  return { handled: true, response: this.error("Sharing is not configured for this environment", 501) };
4798
4879
  }
4799
- const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
4880
+ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
4800
4881
  const m = method.toUpperCase();
4801
4882
  const parts = subPath.replace(/^\/+/, "").split("/").filter(Boolean);
4802
4883
  const ec = context.executionContext;
@@ -4975,7 +5056,13 @@ var _HttpDispatcher = class _HttpDispatcher {
4975
5056
  }
4976
5057
  return this.getObjectQLService(context.environmentId);
4977
5058
  },
4978
- request: context.request
5059
+ request: context.request,
5060
+ // OAuth 2.1 access tokens are honoured ONLY on the MCP
5061
+ // surface (#2698): their coarse tool-family scopes are
5062
+ // enforced at MCP tool dispatch, which other routes don't do.
5063
+ // Matches the plain and `/projects/:id`-scoped route forms
5064
+ // (the scoped prefix is stripped only later, below).
5065
+ acceptOAuthAccessToken: /^(?:\/projects\/[^/]+)?\/mcp(?:[/?]|$)/.test(cleanPath)
4979
5066
  });
4980
5067
  } catch {
4981
5068
  }
@@ -6126,7 +6213,7 @@ function createDispatcherPlugin(config = {}) {
6126
6213
  id: userId,
6127
6214
  displayName: sessionData?.user?.name ?? sessionData?.user?.email ?? userId,
6128
6215
  email: sessionData?.user?.email,
6129
- roles: [],
6216
+ positions: [],
6130
6217
  permissions: [],
6131
6218
  organizationId: sessionData?.session?.activeOrganizationId
6132
6219
  };