@plitzi/sdk-server 0.32.12 → 0.32.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.32.13
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.32.13
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.32.13
10
+ - @plitzi/sdk-schema@0.32.13
11
+ - @plitzi/sdk-shared@0.32.13
12
+
3
13
  ## 0.32.12
4
14
 
5
15
  ### Patch Changes
@@ -1,7 +1,9 @@
1
1
  import { readRawBody } from "../requestParser.js";
2
2
  import { authorizationServerMetadata, protectedResourceMetadata } from "../../modules/oauth/metadata.js";
3
+ import { getAccess } from "../../modules/oauth/records.js";
3
4
  import { sendErrorJson, sendJson } from "../../modules/oauth/respond.js";
4
5
  import { handleAuthorizeStart, handleAuthorizeSubmit } from "../../modules/oauth/authorize.js";
6
+ import { bearerOf, sendChallenge } from "../../modules/oauth/challenge.js";
5
7
  import { handleRegister } from "../../modules/oauth/register.js";
6
8
  import { handleToken } from "../../modules/oauth/token.js";
7
9
  //#region src/core/services/oauth.ts
@@ -67,5 +69,37 @@ var oauthStage = async (ctx) => {
67
69
  sendErrorJson(res, 405, "invalid_request", `${method} is not allowed on ${path}.`);
68
70
  return true;
69
71
  };
72
+ /** Fail-closed on purpose: a credential this server cannot check right now — an unreachable store, an adapter that
73
+ * threw — is not one it may act on, and 401 is the answer a host can do something about (re-authorize) where a
74
+ * 500 leaves it stuck. */
75
+ var verified = async (check) => {
76
+ try {
77
+ return await check();
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+ var isAuthorized = async (oauth, ctx, token) => {
83
+ if (await verified(async () => await getAccess(oauth.adapters.store, token) !== void 0)) return true;
84
+ const { adapters } = ctx.config;
85
+ return verified(async () => await adapters.getSpaceId?.(ctx.req) !== void 0);
86
+ };
87
+ /** The protected-resource half of OAuth: an MCP call that presents no bearer this server can verify is refused
88
+ * with RFC 6750's challenge instead of being served the anonymous surface. That 401 is the whole handshake — it
89
+ * is how a host learns the server needs authorization, where its metadata lives and which scopes to ask for, and
90
+ * a 200 tells it none of that. Only the JSON-RPC POST is guarded: the CORS preflight, the GET 405 and the
91
+ * discovery probes carry no credential and must keep answering as they do.
92
+ *
93
+ * Mounted only when `oauth` is configured. A deployment that configures none keeps the open server it had, where
94
+ * the whole public surface — handshake, listings, the guide, plitzi_render — answers without a token; with OAuth
95
+ * on, the grant that carries no space is what covers that same ground. */
96
+ var oauthGuardStage = async (ctx) => {
97
+ const { oauth } = ctx.config;
98
+ if (!oauth || ctx.req.method !== "POST") return false;
99
+ const token = bearerOf(ctx.req);
100
+ if (token && await isAuthorized(oauth, ctx, token)) return false;
101
+ sendChallenge(oauth, ctx.req, ctx.res, token ? "The access token is invalid, expired or revoked." : "Authorization is required to use this server.");
102
+ return true;
103
+ };
70
104
  //#endregion
71
- export { oauthStage };
105
+ export { oauthGuardStage, oauthStage };
@@ -1,5 +1,5 @@
1
1
  import { mcpOnlyStage, mcpStage } from "./mcp.js";
2
- import { oauthStage } from "./oauth.js";
2
+ import { oauthGuardStage, oauthStage } from "./oauth.js";
3
3
  import { previewStage } from "./preview.js";
4
4
  import { rscStage } from "./rsc.js";
5
5
  import { notFoundStage, ssrStage } from "./ssr.js";
@@ -30,6 +30,7 @@ var buildMCPPipeline = () => [
30
30
  healthStage,
31
31
  configStaticStage,
32
32
  oauthStage,
33
+ oauthGuardStage,
33
34
  mcpOnlyStage
34
35
  ];
35
36
  //#endregion
@@ -43,7 +43,7 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
43
43
  const getSpace = () => spacePromise ??= loadSpace();
44
44
  const server = new McpServer({
45
45
  name: "plitzi-mcp",
46
- version: "0.32.12"
46
+ version: "0.32.13"
47
47
  }, { instructions: serverInstructions });
48
48
  registerResources(server, getSpace, MCP_ENV, log);
49
49
  registerApps(server);
@@ -0,0 +1,33 @@
1
+ import { resourceMetadataUrl, scopesOf } from "./metadata.js";
2
+ import { sendJson } from "./respond.js";
3
+ //#region src/modules/oauth/challenge.ts
4
+ /** The credential on an MCP request. `Authorization: Bearer` is what RFC 6750 defines and what a remote host
5
+ * sends; `x-access-token` is the platform's own header, which the builder and the CLI already use — a request
6
+ * carrying either presents a credential, and the same verification decides whether it is a good one. */
7
+ var bearerOf = (req) => {
8
+ const header = req.headers["x-access-token"] ?? req.headers.authorization ?? "";
9
+ return (Array.isArray(header) ? header[0] ?? "" : header).replace(/^Bearer\s+/i, "").trim();
10
+ };
11
+ var parameter = (name, value) => `${name}="${value.replace(/"/gu, "")}"`;
12
+ /** RFC 6750 §3 — the answer to an MCP request that presents no usable credential, and the only thing that starts
13
+ * an authorization flow: a host runs OAuth off a 401 whose `WWW-Authenticate` names the resource metadata, and
14
+ * IGNORES the header on a 200. Answering such a request with the anonymous surface instead is what leaves a
15
+ * connector unable to attach the grant it just completed — the flow succeeds and the host still reports that
16
+ * authorization failed. `scope` states what to ask for, so consent is not widened to everything advertised. */
17
+ var sendChallenge = (config, req, res, description) => {
18
+ const params = [
19
+ parameter("error", "invalid_token"),
20
+ parameter("error_description", description),
21
+ parameter("resource_metadata", resourceMetadataUrl(config, req)),
22
+ parameter("scope", scopesOf(config).join(" "))
23
+ ];
24
+ res.setHeader("WWW-Authenticate", `Bearer ${params.join(", ")}`);
25
+ res.setHeader("Access-Control-Allow-Origin", "*");
26
+ res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate");
27
+ sendJson(res, 401, {
28
+ error: "invalid_token",
29
+ error_description: description
30
+ });
31
+ };
32
+ //#endregion
33
+ export { bearerOf, sendChallenge };
@@ -11,22 +11,35 @@ var DEFAULT_SCOPES = ["plitzi"];
11
11
  * deployment correct across its dev, staging and production hosts without per-environment config. */
12
12
  var issuerOf = (config, req) => config.issuer ?? requestOrigin(req);
13
13
  var scopesOf = (config) => config.scopes ?? DEFAULT_SCOPES;
14
+ var canonicalPath = (path) => path.replace(/\/+$/, "");
15
+ /** Where the challenge points a host, for an MCP endpoint served at `req.path`. RFC 9728 §3.1 appends the
16
+ * resource's own path to the well-known path, and a host that reads the document back expects its `resource` to
17
+ * name the URL it was configured with — so a server mounted at /mcp must be pointed at the suffixed document,
18
+ * not the bare one. */
19
+ var resourceMetadataUrl = (config, req) => `${issuerOf(config, req)}${PROTECTED_RESOURCE_PATH}${canonicalPath(req.path)}`;
14
20
  /** RFC 9728. The document Claude Desktop asks for FIRST, and the one whose absence ends the flow before anything
15
- * else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`. */
21
+ * else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`.
22
+ *
23
+ * `resource` echoes the path the document was asked for: Claude requires it to match the server URL the user
24
+ * typed, path included, and a dedicated MCP server answers JSON-RPC on every path — so `/.well-known/…/mcp`
25
+ * describes `https://host/mcp` while the bare path describes the origin. */
16
26
  var protectedResourceMetadata = (config, req) => {
17
27
  const issuer = issuerOf(config, req);
18
28
  return {
19
- resource: issuer,
29
+ resource: `${issuer}${canonicalPath(req.path.slice(37))}`,
20
30
  authorization_servers: [issuer],
21
31
  scopes_supported: scopesOf(config),
22
32
  bearer_methods_supported: ["header"]
23
33
  };
24
34
  };
25
35
  /** RFC 8414. Public clients with PKCE only: a desktop host stores no secret, so `none` is the sole endpoint auth
26
- * method and S256 the sole challenge method. */
36
+ * method and S256 the sole challenge method. `offline_access` is advertised whenever refresh grants are issued,
37
+ * which is the signal a host looks for before asking for one. */
27
38
  var authorizationServerMetadata = (config, req) => {
28
39
  const issuer = issuerOf(config, req);
29
- const grantTypes = config.refreshTtlSeconds === 0 ? ["authorization_code"] : ["authorization_code", "refresh_token"];
40
+ const refreshes = config.refreshTtlSeconds !== 0;
41
+ const grantTypes = refreshes ? ["authorization_code", "refresh_token"] : ["authorization_code"];
42
+ const scopes = refreshes ? [...scopesOf(config), "offline_access"] : scopesOf(config);
30
43
  return {
31
44
  issuer,
32
45
  authorization_endpoint: `${issuer}${AUTHORIZE_PATH}`,
@@ -36,8 +49,8 @@ var authorizationServerMetadata = (config, req) => {
36
49
  grant_types_supported: grantTypes,
37
50
  code_challenge_methods_supported: ["S256"],
38
51
  token_endpoint_auth_methods_supported: ["none"],
39
- scopes_supported: scopesOf(config)
52
+ scopes_supported: scopes
40
53
  };
41
54
  };
42
55
  //#endregion
43
- export { AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, PROTECTED_RESOURCE_PATH, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, issuerOf, protectedResourceMetadata, scopesOf };
56
+ export { AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, PROTECTED_RESOURCE_PATH, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, issuerOf, protectedResourceMetadata, resourceMetadataUrl, scopesOf };
@@ -31,5 +31,7 @@ var getRefresh = (store, token) => readJson(store, "refresh", token);
31
31
  var dropRefresh = async (store, token) => {
32
32
  await store.drop(keyOf("refresh", token));
33
33
  };
34
+ var putAccess = (store, token, record, ttlSeconds) => writeJson(store, "access", token, record, ttlSeconds);
35
+ var getAccess = (store, token) => readJson(store, "access", token);
34
36
  //#endregion
35
- export { dropCode, dropPending, dropRefresh, getClient, getCode, getPending, getRefresh, putClient, putCode, putPending, putRefresh };
37
+ export { dropCode, dropPending, dropRefresh, getAccess, getClient, getCode, getPending, getRefresh, putAccess, putClient, putCode, putPending, putRefresh };
@@ -1,10 +1,11 @@
1
1
  import { scopesOf } from "./metadata.js";
2
2
  import { field, optionalField } from "./params.js";
3
3
  import { randomId, verifyChallenge } from "./pkce.js";
4
- import { dropCode, dropRefresh, getCode, getRefresh, putRefresh } from "./records.js";
4
+ import { dropCode, dropRefresh, getCode, getRefresh, putAccess, putRefresh } from "./records.js";
5
5
  import { sendErrorJson, sendJson } from "./respond.js";
6
6
  //#region src/modules/oauth/token.ts
7
7
  var DEFAULT_REFRESH_TTL_SECONDS = 3600 * 24 * 30;
8
+ var DEFAULT_ACCESS_TTL_SECONDS = 3600 * 24 * 30;
8
9
  var refreshTtlOf = (config) => config.refreshTtlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS;
9
10
  var scopeOf = (config, requested) => requested ?? scopesOf(config).join(" ");
10
11
  /** The token response, plus a rotated refresh grant when refresh is enabled. Rotation is unconditional: a refresh
@@ -16,6 +17,11 @@ var sendTokens = async (config, res, token, expiresInSeconds, grant) => {
16
17
  token_type: "Bearer",
17
18
  scope: scopeOf(config, grant.scope)
18
19
  };
20
+ await putAccess(config.adapters.store, token, {
21
+ clientId: grant.clientId,
22
+ user: grant.user,
23
+ target: grant.target
24
+ }, Math.max(expiresInSeconds ?? DEFAULT_ACCESS_TTL_SECONDS, 1));
19
25
  if (expiresInSeconds !== void 0) body["expires_in"] = expiresInSeconds;
20
26
  if (ttl > 0) {
21
27
  const refreshToken = randomId();
@@ -6,3 +6,13 @@ import { Stage } from '../http/types';
6
6
  * It sits before the MCP stage because that one answers every path on a dedicated MCP server — these endpoints
7
7
  * would otherwise be swallowed by the JSON-RPC transport, which is exactly the 406 a host hits on /register. */
8
8
  export declare const oauthStage: Stage;
9
+ /** The protected-resource half of OAuth: an MCP call that presents no bearer this server can verify is refused
10
+ * with RFC 6750's challenge instead of being served the anonymous surface. That 401 is the whole handshake — it
11
+ * is how a host learns the server needs authorization, where its metadata lives and which scopes to ask for, and
12
+ * a 200 tells it none of that. Only the JSON-RPC POST is guarded: the CORS preflight, the GET 405 and the
13
+ * discovery probes carry no credential and must keep answering as they do.
14
+ *
15
+ * Mounted only when `oauth` is configured. A deployment that configures none keeps the open server it had, where
16
+ * the whole public surface — handshake, listings, the guide, plitzi_render — answers without a token; with OAuth
17
+ * on, the grant that carries no space is what covers that same ground. */
18
+ export declare const oauthGuardStage: Stage;
@@ -0,0 +1,11 @@
1
+ import { OAuthConfig, SSRRequest, SSRResponseHelpers } from '@plitzi/sdk-shared';
2
+ /** The credential on an MCP request. `Authorization: Bearer` is what RFC 6750 defines and what a remote host
3
+ * sends; `x-access-token` is the platform's own header, which the builder and the CLI already use — a request
4
+ * carrying either presents a credential, and the same verification decides whether it is a good one. */
5
+ export declare const bearerOf: (req: SSRRequest) => string;
6
+ /** RFC 6750 §3 — the answer to an MCP request that presents no usable credential, and the only thing that starts
7
+ * an authorization flow: a host runs OAuth off a 401 whose `WWW-Authenticate` names the resource metadata, and
8
+ * IGNORES the header on a 200. Answering such a request with the anonymous surface instead is what leaves a
9
+ * connector unable to attach the grant it just completed — the flow succeeds and the host still reports that
10
+ * authorization failed. `scope` states what to ask for, so consent is not widened to everything advertised. */
11
+ export declare const sendChallenge: (config: OAuthConfig, req: SSRRequest, res: SSRResponseHelpers, description: string) => void;
@@ -9,9 +9,19 @@ export declare const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorizati
9
9
  * deployment correct across its dev, staging and production hosts without per-environment config. */
10
10
  export declare const issuerOf: (config: OAuthConfig, req: SSRRequest) => string;
11
11
  export declare const scopesOf: (config: OAuthConfig) => string[];
12
+ /** Where the challenge points a host, for an MCP endpoint served at `req.path`. RFC 9728 §3.1 appends the
13
+ * resource's own path to the well-known path, and a host that reads the document back expects its `resource` to
14
+ * name the URL it was configured with — so a server mounted at /mcp must be pointed at the suffixed document,
15
+ * not the bare one. */
16
+ export declare const resourceMetadataUrl: (config: OAuthConfig, req: SSRRequest) => string;
12
17
  /** RFC 9728. The document Claude Desktop asks for FIRST, and the one whose absence ends the flow before anything
13
- * else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`. */
18
+ * else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`.
19
+ *
20
+ * `resource` echoes the path the document was asked for: Claude requires it to match the server URL the user
21
+ * typed, path included, and a dedicated MCP server answers JSON-RPC on every path — so `/.well-known/…/mcp`
22
+ * describes `https://host/mcp` while the bare path describes the origin. */
14
23
  export declare const protectedResourceMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
15
24
  /** RFC 8414. Public clients with PKCE only: a desktop host stores no secret, so `none` is the sole endpoint auth
16
- * method and S256 the sole challenge method. */
25
+ * method and S256 the sole challenge method. `offline_access` is advertised whenever refresh grants are issued,
26
+ * which is the signal a host looks for before asking for one. */
17
27
  export declare const authorizationServerMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
@@ -36,6 +36,15 @@ export type RefreshRecord = {
36
36
  user: OAuthUser;
37
37
  target: OAuthGrantTarget;
38
38
  };
39
+ /** A bearer this server handed out, kept so the resource side can tell one it issued from a string a client made
40
+ * up: the grant that carries no space mints an opaque token that resolves to nothing, and without a record there
41
+ * is nothing to check it against. The record expires with the token, which is what turns an expired bearer into
42
+ * the 401 a host answers by refreshing; dropping it early revokes the token. */
43
+ export type AccessRecord = {
44
+ clientId: string;
45
+ user: OAuthUser;
46
+ target: OAuthGrantTarget;
47
+ };
39
48
  export declare const putClient: (store: OAuthStore, client: ClientRecord) => Promise<void>;
40
49
  export declare const getClient: (store: OAuthStore, clientId: string) => Promise<ClientRecord | undefined>;
41
50
  export declare const putPending: (store: OAuthStore, id: string, pending: PendingRecord) => Promise<void>;
@@ -47,3 +56,5 @@ export declare const dropCode: (store: OAuthStore, code: string) => Promise<void
47
56
  export declare const putRefresh: (store: OAuthStore, token: string, record: RefreshRecord, ttlSeconds: number) => Promise<void>;
48
57
  export declare const getRefresh: (store: OAuthStore, token: string) => Promise<RefreshRecord | undefined>;
49
58
  export declare const dropRefresh: (store: OAuthStore, token: string) => Promise<void>;
59
+ export declare const putAccess: (store: OAuthStore, token: string, record: AccessRecord, ttlSeconds: number) => Promise<void>;
60
+ export declare const getAccess: (store: OAuthStore, token: string) => Promise<AccessRecord | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.32.12",
3
+ "version": "0.32.13",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist"
@@ -29,9 +29,9 @@
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/ext-apps": "^1.7.5",
31
31
  "@modelcontextprotocol/sdk": "^1.29.0",
32
- "@plitzi/plitzi-sdk": "0.32.12",
33
- "@plitzi/sdk-schema": "0.32.12",
34
- "@plitzi/sdk-shared": "0.32.12",
32
+ "@plitzi/plitzi-sdk": "0.32.13",
33
+ "@plitzi/sdk-schema": "0.32.13",
34
+ "@plitzi/sdk-shared": "0.32.13",
35
35
  "ejs": "^6.0.1",
36
36
  "esbuild": "^0.28.1",
37
37
  "zod": "^4.4.3"