@plitzi/sdk-server 0.32.13 → 0.32.14

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.14
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.32.14
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.32.14
10
+ - @plitzi/sdk-schema@0.32.14
11
+ - @plitzi/sdk-shared@0.32.14
12
+
3
13
  ## 0.32.13
4
14
 
5
15
  ### Patch Changes
@@ -69,20 +69,30 @@ var oauthStage = async (ctx) => {
69
69
  sendErrorJson(res, 405, "invalid_request", `${method} is not allowed on ${path}.`);
70
70
  return true;
71
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) => {
72
+ var CHALLENGE_DESCRIPTIONS = {
73
+ "no-credential": "Authorization is required to use this server.",
74
+ "unknown-credential": "The access token is invalid, expired or revoked.",
75
+ "store-unreachable": "The access token could not be verified right now.",
76
+ "adapter-failed": "The access token could not be verified right now."
77
+ };
78
+ var challengeReason = async (oauth, ctx, token) => {
79
+ if (!token) return "no-credential";
76
80
  try {
77
- return await check();
81
+ const record = await getAccess(oauth.adapters.store, token);
82
+ if (record) {
83
+ const credential = record.credential ?? token;
84
+ ctx.req.headers["x-access-token"] = credential;
85
+ ctx.req.headers.authorization = `Bearer ${credential}`;
86
+ return;
87
+ }
78
88
  } catch {
79
- return false;
89
+ return "store-unreachable";
90
+ }
91
+ try {
92
+ return await ctx.config.adapters.getSpaceId?.(ctx.req) === void 0 ? "unknown-credential" : void 0;
93
+ } catch {
94
+ return "adapter-failed";
80
95
  }
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
96
  };
87
97
  /** The protected-resource half of OAuth: an MCP call that presents no bearer this server can verify is refused
88
98
  * with RFC 6750's challenge instead of being served the anonymous surface. That 401 is the whole handshake — it
@@ -96,9 +106,10 @@ var isAuthorized = async (oauth, ctx, token) => {
96
106
  var oauthGuardStage = async (ctx) => {
97
107
  const { oauth } = ctx.config;
98
108
  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.");
109
+ const reason = await challengeReason(oauth, ctx, bearerOf(ctx.req));
110
+ if (!reason) return false;
111
+ ctx.operation = `oauth-challenge:${reason}`;
112
+ sendChallenge(oauth, ctx.req, ctx.res, CHALLENGE_DESCRIPTIONS[reason]);
102
113
  return true;
103
114
  };
104
115
  //#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.13"
46
+ version: "0.32.14"
47
47
  }, { instructions: serverInstructions });
48
48
  registerResources(server, getSpace, MCP_ENV, log);
49
49
  registerApps(server);
@@ -6,6 +6,15 @@ import { dropPending, getClient, getPending, putCode, putPending } from "./recor
6
6
  import { redirectWithCode, redirectWithError, sendErrorPage, sendHtml } from "./respond.js";
7
7
  //#region src/modules/oauth/authorize.ts
8
8
  var DEFAULT_CODE_TTL_SECONDS = 60;
9
+ var DEFAULT_GUEST_LABEL = "Continue without an account";
10
+ var DEFAULT_GUEST_USER = {
11
+ id: "guest",
12
+ label: "Guest"
13
+ };
14
+ var guestView = (guest) => ({
15
+ label: guest.label ?? DEFAULT_GUEST_LABEL,
16
+ description: guest.target.description
17
+ });
9
18
  var hiddenFieldsFor = (request, pendingId) => {
10
19
  const hidden = {
11
20
  client_id: request.clientId,
@@ -57,8 +66,8 @@ var resolveRequest = async (config, res, params) => {
57
66
  };
58
67
  /** Consent granted: mint the bearer now, park it behind a one-shot code and send the browser back. Minting here
59
68
  * rather than at redemption keeps a failure the user can act on — no space, revoked access — on this screen. */
60
- var completeGrant = async (config, res, request, pending, target) => {
61
- const issued = await config.adapters.issueToken(pending.user, target);
69
+ var completeGrant = async (config, res, request, user, target) => {
70
+ const issued = await config.adapters.issueToken(user, target);
62
71
  if (!issued) {
63
72
  redirectWithError(res, request.redirectUri, "access_denied", "The account may not grant access to this resource.", request.state);
64
73
  return;
@@ -71,7 +80,7 @@ var completeGrant = async (config, res, request, pending, target) => {
71
80
  token: issued.token,
72
81
  expiresInSeconds: issued.expiresInSeconds,
73
82
  scope: request.scope,
74
- user: pending.user,
83
+ user,
75
84
  target
76
85
  }, config.codeTtlSeconds ?? DEFAULT_CODE_TTL_SECONDS);
77
86
  redirectWithCode(res, request.redirectUri, code, request.state);
@@ -85,6 +94,7 @@ var handleAuthorizeStart = async (config, res, params) => {
85
94
  action: AUTHORIZE_PATH,
86
95
  hidden: hiddenFieldsFor(request),
87
96
  targets: [],
97
+ guest: config.guest ? guestView(config.guest) : void 0,
88
98
  branding: config.branding ?? {}
89
99
  });
90
100
  };
@@ -115,7 +125,12 @@ var handleAuthorizeSubmit = async (config, res, params) => {
115
125
  return;
116
126
  }
117
127
  await dropPending(config.adapters.store, pendingId);
118
- await completeGrant(config, res, request, pending, chosen);
128
+ await completeGrant(config, res, request, pending.user, chosen);
129
+ return;
130
+ }
131
+ const { guest } = config;
132
+ if (guest && optionalField(params, "guest")) {
133
+ await completeGrant(config, res, request, guest.user ?? DEFAULT_GUEST_USER, guest.target);
119
134
  return;
120
135
  }
121
136
  const user = await config.adapters.authenticate({
@@ -128,6 +143,7 @@ var handleAuthorizeSubmit = async (config, res, params) => {
128
143
  action: AUTHORIZE_PATH,
129
144
  hidden: hiddenFieldsFor(request),
130
145
  targets: [],
146
+ guest: guest ? guestView(guest) : void 0,
131
147
  error: "Those credentials did not match an account.",
132
148
  branding: config.branding ?? {}
133
149
  });
@@ -35,16 +35,23 @@ var STYLES = `
35
35
  ul.targets span { color: var(--muted); font-size: 13px; }
36
36
  button { width: 100%; padding: 11px 16px; border: 0; border-radius: 8px; background: var(--accent);
37
37
  color: #fff; font: inherit; font-weight: 500; cursor: pointer; }
38
+ button.guest { margin-top: 10px; background: transparent; color: var(--accent);
39
+ border: 1px solid var(--line); }
38
40
  p.error { margin: 0 0 16px; padding: 10px 12px; border-radius: 8px; color: var(--danger);
39
41
  border: 1px solid currentColor; font-size: 14px; }
42
+ p.note { margin: 10px 0 0; color: var(--muted); font-size: 13px; text-align: center; }
40
43
  `;
41
44
  var hiddenFields = (hidden) => Object.entries(hidden).map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("\n ");
42
- var credentialsFields = () => `<label for="username">Email or username</label>
45
+ var guestButton = (guest) => {
46
+ const note = guest.description ? `\n <p class="note">${escapeHtml(guest.description)}</p>` : "";
47
+ return `\n <button class="guest" type="submit" name="guest" value="1" formnovalidate>${escapeHtml(guest.label)}</button>${note}`;
48
+ };
49
+ var credentialsFields = (view) => `<label for="username">Email or username</label>
43
50
  <input id="username" name="username" type="text" autocomplete="username" autocapitalize="none"
44
51
  spellcheck="false" required autofocus>
45
52
  <label for="password">Password</label>
46
53
  <input id="password" name="password" type="password" autocomplete="current-password" required>
47
- <button type="submit">Sign in</button>`;
54
+ <button type="submit">Sign in</button>${view.guest ? guestButton(view.guest) : ""}`;
48
55
  var targetFields = (view) => {
49
56
  return `<ul class="targets">
50
57
  ${view.targets.map((target, index) => {
@@ -82,7 +89,7 @@ var renderConsentPage = (view) => {
82
89
  ${error}
83
90
  <form method="post" action="${escapeHtml(view.action)}">
84
91
  ${hiddenFields(view.hidden)}
85
- ${view.step === "credentials" ? credentialsFields() : targetFields(view)}
92
+ ${view.step === "credentials" ? credentialsFields(view) : targetFields(view)}
86
93
  </form>
87
94
  </main>
88
95
  </body>
@@ -10,18 +10,20 @@ var refreshTtlOf = (config) => config.refreshTtlSeconds ?? DEFAULT_REFRESH_TTL_S
10
10
  var scopeOf = (config, requested) => requested ?? scopesOf(config).join(" ");
11
11
  /** The token response, plus a rotated refresh grant when refresh is enabled. Rotation is unconditional: a refresh
12
12
  * token is a long-lived credential, so the one just used never stays valid. */
13
- var sendTokens = async (config, res, token, expiresInSeconds, grant) => {
13
+ var sendTokens = async (config, res, credential, expiresInSeconds, grant) => {
14
14
  const ttl = refreshTtlOf(config);
15
- const body = {
16
- access_token: token,
17
- token_type: "Bearer",
18
- scope: scopeOf(config, grant.scope)
19
- };
20
- await putAccess(config.adapters.store, token, {
15
+ const bearer = randomId();
16
+ await putAccess(config.adapters.store, bearer, {
17
+ credential,
21
18
  clientId: grant.clientId,
22
19
  user: grant.user,
23
20
  target: grant.target
24
21
  }, Math.max(expiresInSeconds ?? DEFAULT_ACCESS_TTL_SECONDS, 1));
22
+ const body = {
23
+ access_token: bearer,
24
+ token_type: "Bearer",
25
+ scope: scopeOf(config, grant.scope)
26
+ };
25
27
  if (expiresInSeconds !== void 0) body["expires_in"] = expiresInSeconds;
26
28
  if (ttl > 0) {
27
29
  const refreshToken = randomId();
@@ -0,0 +1 @@
1
+ export {};
@@ -36,11 +36,16 @@ 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. */
39
+ /** A bearer this server handed out. The token a client holds is an opaque handle minted here, and this record is
40
+ * what it stands for including `credential`, the thing the deployment's own adapters understand, which never
41
+ * leaves the server: a platform token is usually good against more than the MCP endpoint, and a remote host has
42
+ * no business holding one. The record expires with the bearer, which is what turns an expired one into the 401 a
43
+ * host answers by refreshing; dropping it early revokes the bearer outright. */
43
44
  export type AccessRecord = {
45
+ /** What {@link OAuthAdapters.issueToken} returned — swapped back onto the request once the bearer checks out.
46
+ * Absent only in a record written before the bearer and the credential were separate things, where the bearer
47
+ * WAS the credential: a live connector must survive that upgrade, so the reader falls back to the token itself. */
48
+ credential?: string;
44
49
  clientId: string;
45
50
  user: OAuthUser;
46
51
  target: OAuthGrantTarget;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.32.13",
3
+ "version": "0.32.14",
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.13",
33
- "@plitzi/sdk-schema": "0.32.13",
34
- "@plitzi/sdk-shared": "0.32.13",
32
+ "@plitzi/plitzi-sdk": "0.32.14",
33
+ "@plitzi/sdk-schema": "0.32.14",
34
+ "@plitzi/sdk-shared": "0.32.14",
35
35
  "ejs": "^6.0.1",
36
36
  "esbuild": "^0.28.1",
37
37
  "zod": "^4.4.3"