@plitzi/sdk-server 0.32.10 → 0.32.12
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 +20 -0
- package/dist/core/http/dispatcher.js +48 -10
- package/dist/core/requestParser.js +18 -1
- package/dist/core/services/mcp.js +4 -4
- package/dist/core/services/oauth.js +71 -0
- package/dist/core/services/registry.js +2 -0
- package/dist/core/services/rsc.js +1 -0
- package/dist/helpers/serverLog.js +34 -0
- package/dist/index.js +2 -1
- package/dist/mcp.js +2 -1
- package/dist/modules/mcp/handler.js +10 -0
- package/dist/modules/mcp/helpers/log.js +20 -10
- package/dist/modules/mcp/resources/register.js +10 -4
- package/dist/modules/mcp/resources/renderGuide.js +17 -7
- package/dist/modules/mcp/server.js +1 -1
- package/dist/modules/oauth/authorize.js +160 -0
- package/dist/modules/oauth/consentPage.js +111 -0
- package/dist/modules/oauth/metadata.js +43 -0
- package/dist/modules/oauth/params.js +5 -0
- package/dist/modules/oauth/pkce.js +15 -0
- package/dist/modules/oauth/records.js +35 -0
- package/dist/modules/oauth/register.js +48 -0
- package/dist/modules/oauth/respond.js +44 -0
- package/dist/modules/oauth/token.js +92 -0
- package/dist/plugins/manager.js +2 -0
- package/dist/plugins/validate.js +23 -0
- package/dist/src/core/http/dispatcher.test.d.ts +1 -0
- package/dist/src/core/http/types.d.ts +3 -0
- package/dist/src/core/requestParser.d.ts +5 -0
- package/dist/src/core/services/oauth.d.ts +8 -0
- package/dist/src/helpers/buildResponseHelpers.d.ts +2 -0
- package/dist/src/helpers/serverLog.d.ts +10 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/modules/mcp/handler.d.ts +4 -4
- package/dist/src/modules/mcp/helpers/log.d.ts +3 -3
- package/dist/src/modules/mcp/resources/renderGuide.d.ts +2 -1
- package/dist/src/modules/mcp/server.d.ts +2 -2
- package/dist/src/modules/oauth/authorize.d.ts +7 -0
- package/dist/src/modules/oauth/consentPage.d.ts +7 -0
- package/dist/src/modules/oauth/metadata.d.ts +17 -0
- package/dist/src/modules/oauth/oauth.test.d.ts +1 -0
- package/dist/src/modules/oauth/params.d.ts +5 -0
- package/dist/src/modules/oauth/pkce.d.ts +6 -0
- package/dist/src/modules/oauth/records.d.ts +49 -0
- package/dist/src/modules/oauth/register.d.ts +5 -0
- package/dist/src/modules/oauth/respond.d.ts +13 -0
- package/dist/src/modules/oauth/token.d.ts +5 -0
- package/dist/src/plugins/validate.d.ts +9 -0
- package/dist/src/plugins/validate.test.d.ts +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//#region src/modules/oauth/consentPage.ts
|
|
2
|
+
var escapeHtml = (value) => value.replace(/[&<>"']/gu, (character) => {
|
|
3
|
+
switch (character) {
|
|
4
|
+
case "&": return "&";
|
|
5
|
+
case "<": return "<";
|
|
6
|
+
case ">": return ">";
|
|
7
|
+
case "\"": return """;
|
|
8
|
+
default: return "'";
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
var STYLES = `
|
|
12
|
+
:root { color-scheme: light dark; --bg: #f6f7f9; --panel: #ffffff; --ink: #16181d; --muted: #6b7280;
|
|
13
|
+
--line: #e2e5ea; --accent: #2563eb; --danger: #b91c1c; }
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root { --bg: #0f1115; --panel: #171a21; --ink: #e8eaee; --muted: #9aa1ad; --line: #262b35;
|
|
16
|
+
--accent: #60a5fa; --danger: #f87171; }
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
|
|
20
|
+
background: var(--bg); color: var(--ink);
|
|
21
|
+
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
22
|
+
main { width: 100%; max-width: 420px; background: var(--panel); border: 1px solid var(--line);
|
|
23
|
+
border-radius: 14px; padding: 32px; }
|
|
24
|
+
img.logo { display: block; height: 32px; margin-bottom: 20px; }
|
|
25
|
+
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
|
|
26
|
+
p.lede { margin: 0 0 24px; color: var(--muted); font-size: 14px; }
|
|
27
|
+
label { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; }
|
|
28
|
+
input[type="text"], input[type="password"] { width: 100%; margin-bottom: 16px; padding: 10px 12px;
|
|
29
|
+
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); font: inherit; }
|
|
30
|
+
input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
31
|
+
ul.targets { list-style: none; margin: 0 0 20px; padding: 0; display: grid; gap: 8px; }
|
|
32
|
+
ul.targets label { display: flex; gap: 10px; align-items: flex-start; margin: 0; padding: 12px;
|
|
33
|
+
border: 1px solid var(--line); border-radius: 8px; font-weight: 400; cursor: pointer; }
|
|
34
|
+
ul.targets strong { display: block; font-weight: 500; }
|
|
35
|
+
ul.targets span { color: var(--muted); font-size: 13px; }
|
|
36
|
+
button { width: 100%; padding: 11px 16px; border: 0; border-radius: 8px; background: var(--accent);
|
|
37
|
+
color: #fff; font: inherit; font-weight: 500; cursor: pointer; }
|
|
38
|
+
p.error { margin: 0 0 16px; padding: 10px 12px; border-radius: 8px; color: var(--danger);
|
|
39
|
+
border: 1px solid currentColor; font-size: 14px; }
|
|
40
|
+
`;
|
|
41
|
+
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>
|
|
43
|
+
<input id="username" name="username" type="text" autocomplete="username" autocapitalize="none"
|
|
44
|
+
spellcheck="false" required autofocus>
|
|
45
|
+
<label for="password">Password</label>
|
|
46
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
47
|
+
<button type="submit">Sign in</button>`;
|
|
48
|
+
var targetFields = (view) => {
|
|
49
|
+
return `<ul class="targets">
|
|
50
|
+
${view.targets.map((target, index) => {
|
|
51
|
+
const description = target.description ? `<span>${escapeHtml(target.description)}</span>` : "";
|
|
52
|
+
return `<li><label>
|
|
53
|
+
<input type="radio" name="target" value="${escapeHtml(target.value)}"${index === 0 ? " checked" : ""}>
|
|
54
|
+
<span><strong>${escapeHtml(target.label)}</strong>${description}</span>
|
|
55
|
+
</label></li>`;
|
|
56
|
+
}).join("\n ")}
|
|
57
|
+
</ul>
|
|
58
|
+
<button type="submit">Allow access</button>`;
|
|
59
|
+
};
|
|
60
|
+
/** The built-in consent screen: a credentials step, then a step to pick what the client gets access to. Replace it
|
|
61
|
+
* wholesale via `oauth.renderConsent` — the field names read back here are the contract, not the markup. */
|
|
62
|
+
var renderConsentPage = (view) => {
|
|
63
|
+
const productName = view.branding.productName ?? "Plitzi";
|
|
64
|
+
const logo = view.branding.logoUrl ? `<img class="logo" src="${escapeHtml(view.branding.logoUrl)}" alt="${escapeHtml(productName)}">` : "";
|
|
65
|
+
const error = view.error ? `<p class="error">${escapeHtml(view.error)}</p>` : "";
|
|
66
|
+
const lede = view.step === "credentials" ? `Sign in to connect ${escapeHtml(productName)}.` : `Signed in as ${escapeHtml(view.user?.label ?? "")}. Choose what to grant access to.`;
|
|
67
|
+
return `<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
+
<meta name="robots" content="noindex">
|
|
73
|
+
<link rel="icon" href="data:,">
|
|
74
|
+
<title>${escapeHtml(productName)}</title>
|
|
75
|
+
<style>${STYLES}${view.branding.css ?? ""}</style>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<main>
|
|
79
|
+
${logo}
|
|
80
|
+
<h1>${escapeHtml(productName)}</h1>
|
|
81
|
+
<p class="lede">${lede}</p>
|
|
82
|
+
${error}
|
|
83
|
+
<form method="post" action="${escapeHtml(view.action)}">
|
|
84
|
+
${hiddenFields(view.hidden)}
|
|
85
|
+
${view.step === "credentials" ? credentialsFields() : targetFields(view)}
|
|
86
|
+
</form>
|
|
87
|
+
</main>
|
|
88
|
+
</body>
|
|
89
|
+
</html>`;
|
|
90
|
+
};
|
|
91
|
+
/** The dead end for a request that cannot be redirected back — a bad `redirect_uri` or an unknown client. Bouncing
|
|
92
|
+
* those to the client would make this server an open redirector, so the user is told here instead. */
|
|
93
|
+
var renderErrorPage = (title, detail) => `<!doctype html>
|
|
94
|
+
<html lang="en">
|
|
95
|
+
<head>
|
|
96
|
+
<meta charset="utf-8">
|
|
97
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
98
|
+
<meta name="robots" content="noindex">
|
|
99
|
+
<link rel="icon" href="data:,">
|
|
100
|
+
<title>${escapeHtml(title)}</title>
|
|
101
|
+
<style>${STYLES}</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<main>
|
|
105
|
+
<h1>${escapeHtml(title)}</h1>
|
|
106
|
+
<p class="lede">${escapeHtml(detail)}</p>
|
|
107
|
+
</main>
|
|
108
|
+
</body>
|
|
109
|
+
</html>`;
|
|
110
|
+
//#endregion
|
|
111
|
+
export { renderConsentPage, renderErrorPage };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { requestOrigin } from "../../core/requestParser.js";
|
|
2
|
+
//#region src/modules/oauth/metadata.ts
|
|
3
|
+
var AUTHORIZE_PATH = "/authorize";
|
|
4
|
+
var TOKEN_PATH = "/token";
|
|
5
|
+
var REGISTER_PATH = "/register";
|
|
6
|
+
var PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
|
7
|
+
var AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
|
|
8
|
+
var DEFAULT_SCOPES = ["plitzi"];
|
|
9
|
+
/** The identifier the two documents agree on. This server is both the protected resource and its own
|
|
10
|
+
* authorization server, so one origin names both. Derived from the request unless pinned, which keeps a
|
|
11
|
+
* deployment correct across its dev, staging and production hosts without per-environment config. */
|
|
12
|
+
var issuerOf = (config, req) => config.issuer ?? requestOrigin(req);
|
|
13
|
+
var scopesOf = (config) => config.scopes ?? DEFAULT_SCOPES;
|
|
14
|
+
/** 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`. */
|
|
16
|
+
var protectedResourceMetadata = (config, req) => {
|
|
17
|
+
const issuer = issuerOf(config, req);
|
|
18
|
+
return {
|
|
19
|
+
resource: issuer,
|
|
20
|
+
authorization_servers: [issuer],
|
|
21
|
+
scopes_supported: scopesOf(config),
|
|
22
|
+
bearer_methods_supported: ["header"]
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** 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. */
|
|
27
|
+
var authorizationServerMetadata = (config, req) => {
|
|
28
|
+
const issuer = issuerOf(config, req);
|
|
29
|
+
const grantTypes = config.refreshTtlSeconds === 0 ? ["authorization_code"] : ["authorization_code", "refresh_token"];
|
|
30
|
+
return {
|
|
31
|
+
issuer,
|
|
32
|
+
authorization_endpoint: `${issuer}${AUTHORIZE_PATH}`,
|
|
33
|
+
token_endpoint: `${issuer}${TOKEN_PATH}`,
|
|
34
|
+
registration_endpoint: `${issuer}${REGISTER_PATH}`,
|
|
35
|
+
response_types_supported: ["code"],
|
|
36
|
+
grant_types_supported: grantTypes,
|
|
37
|
+
code_challenge_methods_supported: ["S256"],
|
|
38
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
39
|
+
scopes_supported: scopesOf(config)
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, PROTECTED_RESOURCE_PATH, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, issuerOf, protectedResourceMetadata, scopesOf };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/modules/oauth/pkce.ts
|
|
3
|
+
/** An unguessable, URL-safe identifier for the values this layer hands out (codes, refresh tokens, client ids).
|
|
4
|
+
* 256 bits from the CSPRNG, so they are also unguessable by anyone who collects a few of them. */
|
|
5
|
+
var randomId = () => randomBytes(32).toString("base64url");
|
|
6
|
+
/** RFC 7636 S256, the only method this server accepts — `plain` puts the verifier on the wire, and a public
|
|
7
|
+
* client on a desktop host has no other proof it is the one that started the flow. */
|
|
8
|
+
var verifyChallenge = (verifier, challenge) => {
|
|
9
|
+
if (!verifier || !challenge) return false;
|
|
10
|
+
const computed = Buffer.from(createHash("sha256").update(verifier).digest("base64url"));
|
|
11
|
+
const expected = Buffer.from(challenge);
|
|
12
|
+
return computed.length === expected.length && timingSafeEqual(computed, expected);
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { randomId, verifyChallenge };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/modules/oauth/records.ts
|
|
2
|
+
var CLIENT_TTL_SECONDS = 3600 * 24 * 90;
|
|
3
|
+
var PENDING_TTL_SECONDS = 600;
|
|
4
|
+
var keyOf = (kind, id) => `oauth:${kind}:${id}`;
|
|
5
|
+
var readJson = async (store, kind, id) => {
|
|
6
|
+
const raw = await store.get(keyOf(kind, id));
|
|
7
|
+
if (!raw) return;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var writeJson = async (store, kind, id, value, ttlSeconds) => {
|
|
15
|
+
await store.put(keyOf(kind, id), JSON.stringify(value), ttlSeconds);
|
|
16
|
+
};
|
|
17
|
+
var putClient = (store, client) => writeJson(store, "client", client.clientId, client, CLIENT_TTL_SECONDS);
|
|
18
|
+
var getClient = (store, clientId) => readJson(store, "client", clientId);
|
|
19
|
+
var putPending = (store, id, pending) => writeJson(store, "pending", id, pending, PENDING_TTL_SECONDS);
|
|
20
|
+
var getPending = (store, id) => readJson(store, "pending", id);
|
|
21
|
+
var dropPending = async (store, id) => {
|
|
22
|
+
await store.drop(keyOf("pending", id));
|
|
23
|
+
};
|
|
24
|
+
var putCode = (store, code, record, ttlSeconds) => writeJson(store, "code", code, record, ttlSeconds);
|
|
25
|
+
var getCode = (store, code) => readJson(store, "code", code);
|
|
26
|
+
var dropCode = async (store, code) => {
|
|
27
|
+
await store.drop(keyOf("code", code));
|
|
28
|
+
};
|
|
29
|
+
var putRefresh = (store, token, record, ttlSeconds) => writeJson(store, "refresh", token, record, ttlSeconds);
|
|
30
|
+
var getRefresh = (store, token) => readJson(store, "refresh", token);
|
|
31
|
+
var dropRefresh = async (store, token) => {
|
|
32
|
+
await store.drop(keyOf("refresh", token));
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { dropCode, dropPending, dropRefresh, getClient, getCode, getPending, getRefresh, putClient, putCode, putPending, putRefresh };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { randomId } from "./pkce.js";
|
|
2
|
+
import { putClient } from "./records.js";
|
|
3
|
+
import { sendErrorJson, sendJson } from "./respond.js";
|
|
4
|
+
//#region src/modules/oauth/register.ts
|
|
5
|
+
var isUsableRedirectUri = (value) => {
|
|
6
|
+
let url;
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(value);
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (url.protocol === "https:") return true;
|
|
13
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
14
|
+
};
|
|
15
|
+
var stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
16
|
+
/** RFC 7591 dynamic client registration. A remote host has no way to be configured into this server ahead of
|
|
17
|
+
* time, so it registers itself on first connect; the record it gets back is only ever used to pin the redirect
|
|
18
|
+
* target, since a public client authenticates with PKCE rather than with credentials. */
|
|
19
|
+
var handleRegister = async (config, res, body) => {
|
|
20
|
+
if (typeof body !== "object" || body === null) {
|
|
21
|
+
sendErrorJson(res, 400, "invalid_request", "Expected a JSON client metadata object.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const metadata = body;
|
|
25
|
+
const redirectUris = stringList(metadata["redirect_uris"]).filter(isUsableRedirectUri);
|
|
26
|
+
if (redirectUris.length === 0) {
|
|
27
|
+
sendErrorJson(res, 400, "invalid_request", "redirect_uris must list at least one https (or loopback) URI.");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const clientName = typeof metadata["client_name"] === "string" ? metadata["client_name"] : "MCP client";
|
|
31
|
+
const clientId = randomId();
|
|
32
|
+
await putClient(config.adapters.store, {
|
|
33
|
+
clientId,
|
|
34
|
+
clientName,
|
|
35
|
+
redirectUris
|
|
36
|
+
});
|
|
37
|
+
sendJson(res, 201, {
|
|
38
|
+
client_id: clientId,
|
|
39
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3),
|
|
40
|
+
client_name: clientName,
|
|
41
|
+
redirect_uris: redirectUris,
|
|
42
|
+
grant_types: config.refreshTtlSeconds === 0 ? ["authorization_code"] : ["authorization_code", "refresh_token"],
|
|
43
|
+
response_types: ["code"],
|
|
44
|
+
token_endpoint_auth_method: "none"
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { handleRegister };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { renderErrorPage } from "./consentPage.js";
|
|
2
|
+
//#region src/modules/oauth/respond.ts
|
|
3
|
+
var sendJson = (res, status, body) => {
|
|
4
|
+
res.setStatus(status);
|
|
5
|
+
res.setHeader("Content-Type", "application/json");
|
|
6
|
+
res.setHeader("Cache-Control", "no-store");
|
|
7
|
+
res.send(JSON.stringify(body));
|
|
8
|
+
};
|
|
9
|
+
var sendHtml = (res, status, html) => {
|
|
10
|
+
res.setStatus(status);
|
|
11
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
12
|
+
res.setHeader("Cache-Control", "no-store");
|
|
13
|
+
res.send(html);
|
|
14
|
+
};
|
|
15
|
+
var sendErrorJson = (res, status, error, description) => sendJson(res, status, {
|
|
16
|
+
error,
|
|
17
|
+
error_description: description
|
|
18
|
+
});
|
|
19
|
+
/** Shown when the authorization request itself is unusable — an unknown client, a `redirect_uri` that is not
|
|
20
|
+
* registered. Redirecting those back would turn the endpoint into an open redirector. */
|
|
21
|
+
var sendErrorPage = (res, title, detail) => sendHtml(res, 400, renderErrorPage(title, detail));
|
|
22
|
+
/** The other half of RFC 6749 error handling: once the redirect target is known to be legitimate, failures go
|
|
23
|
+
* back to the client so the host can report them, carrying `state` so it can match the response to its request. */
|
|
24
|
+
var redirectWithError = (res, redirectUri, error, description, state) => {
|
|
25
|
+
const url = new URL(redirectUri);
|
|
26
|
+
url.searchParams.set("error", error);
|
|
27
|
+
url.searchParams.set("error_description", description);
|
|
28
|
+
if (state !== void 0) url.searchParams.set("state", state);
|
|
29
|
+
res.setStatus(302);
|
|
30
|
+
res.setHeader("Location", url.toString());
|
|
31
|
+
res.setHeader("Cache-Control", "no-store");
|
|
32
|
+
res.end();
|
|
33
|
+
};
|
|
34
|
+
var redirectWithCode = (res, redirectUri, code, state) => {
|
|
35
|
+
const url = new URL(redirectUri);
|
|
36
|
+
url.searchParams.set("code", code);
|
|
37
|
+
if (state !== void 0) url.searchParams.set("state", state);
|
|
38
|
+
res.setStatus(302);
|
|
39
|
+
res.setHeader("Location", url.toString());
|
|
40
|
+
res.setHeader("Cache-Control", "no-store");
|
|
41
|
+
res.end();
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { redirectWithCode, redirectWithError, sendErrorJson, sendErrorPage, sendHtml, sendJson };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { scopesOf } from "./metadata.js";
|
|
2
|
+
import { field, optionalField } from "./params.js";
|
|
3
|
+
import { randomId, verifyChallenge } from "./pkce.js";
|
|
4
|
+
import { dropCode, dropRefresh, getCode, getRefresh, putRefresh } from "./records.js";
|
|
5
|
+
import { sendErrorJson, sendJson } from "./respond.js";
|
|
6
|
+
//#region src/modules/oauth/token.ts
|
|
7
|
+
var DEFAULT_REFRESH_TTL_SECONDS = 3600 * 24 * 30;
|
|
8
|
+
var refreshTtlOf = (config) => config.refreshTtlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS;
|
|
9
|
+
var scopeOf = (config, requested) => requested ?? scopesOf(config).join(" ");
|
|
10
|
+
/** The token response, plus a rotated refresh grant when refresh is enabled. Rotation is unconditional: a refresh
|
|
11
|
+
* token is a long-lived credential, so the one just used never stays valid. */
|
|
12
|
+
var sendTokens = async (config, res, token, expiresInSeconds, grant) => {
|
|
13
|
+
const ttl = refreshTtlOf(config);
|
|
14
|
+
const body = {
|
|
15
|
+
access_token: token,
|
|
16
|
+
token_type: "Bearer",
|
|
17
|
+
scope: scopeOf(config, grant.scope)
|
|
18
|
+
};
|
|
19
|
+
if (expiresInSeconds !== void 0) body["expires_in"] = expiresInSeconds;
|
|
20
|
+
if (ttl > 0) {
|
|
21
|
+
const refreshToken = randomId();
|
|
22
|
+
await putRefresh(config.adapters.store, refreshToken, grant, ttl);
|
|
23
|
+
body["refresh_token"] = refreshToken;
|
|
24
|
+
}
|
|
25
|
+
sendJson(res, 200, body);
|
|
26
|
+
};
|
|
27
|
+
var exchangeCode = async (config, res, params) => {
|
|
28
|
+
const code = field(params, "code");
|
|
29
|
+
const record = code ? await getCode(config.adapters.store, code) : void 0;
|
|
30
|
+
if (!record) {
|
|
31
|
+
sendErrorJson(res, 400, "invalid_grant", "The authorization code is unknown or has expired.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await dropCode(config.adapters.store, code);
|
|
35
|
+
if (record.clientId !== field(params, "client_id")) {
|
|
36
|
+
sendErrorJson(res, 400, "invalid_grant", "The authorization code was issued to another client.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const redirectUri = optionalField(params, "redirect_uri");
|
|
40
|
+
if (redirectUri !== void 0 && redirectUri !== record.redirectUri) {
|
|
41
|
+
sendErrorJson(res, 400, "invalid_grant", "redirect_uri does not match the authorization request.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!verifyChallenge(field(params, "code_verifier"), record.challenge)) {
|
|
45
|
+
sendErrorJson(res, 400, "invalid_grant", "The PKCE code verifier does not match the challenge.");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await sendTokens(config, res, record.token, record.expiresInSeconds, {
|
|
49
|
+
clientId: record.clientId,
|
|
50
|
+
scope: record.scope,
|
|
51
|
+
user: record.user,
|
|
52
|
+
target: record.target
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
var exchangeRefresh = async (config, res, params) => {
|
|
56
|
+
if (refreshTtlOf(config) === 0) {
|
|
57
|
+
sendErrorJson(res, 400, "unsupported_grant_type", "This server issues no refresh tokens.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const refreshToken = field(params, "refresh_token");
|
|
61
|
+
const record = refreshToken ? await getRefresh(config.adapters.store, refreshToken) : void 0;
|
|
62
|
+
if (!record) {
|
|
63
|
+
sendErrorJson(res, 400, "invalid_grant", "The refresh token is unknown or has expired.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await dropRefresh(config.adapters.store, refreshToken);
|
|
67
|
+
if (record.clientId !== field(params, "client_id")) {
|
|
68
|
+
sendErrorJson(res, 400, "invalid_grant", "The refresh token was issued to another client.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const issued = await config.adapters.issueToken(record.user, record.target);
|
|
72
|
+
if (!issued) {
|
|
73
|
+
sendErrorJson(res, 400, "invalid_grant", "The account no longer has access to this resource.");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await sendTokens(config, res, issued.token, issued.expiresInSeconds, record);
|
|
77
|
+
};
|
|
78
|
+
/** POST /token. Public clients only, so there is no client authentication to check — PKCE is what proves the
|
|
79
|
+
* caller is the one that started the flow. */
|
|
80
|
+
var handleToken = async (config, res, params) => {
|
|
81
|
+
switch (field(params, "grant_type")) {
|
|
82
|
+
case "authorization_code":
|
|
83
|
+
await exchangeCode(config, res, params);
|
|
84
|
+
return;
|
|
85
|
+
case "refresh_token":
|
|
86
|
+
await exchangeRefresh(config, res, params);
|
|
87
|
+
return;
|
|
88
|
+
default: sendErrorJson(res, 400, "unsupported_grant_type", "Supported grants: authorization_code, refresh_token.");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
//#endregion
|
|
92
|
+
export { handleToken };
|
package/dist/plugins/manager.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { compilePlugin } from "./compile.js";
|
|
2
2
|
import { copyPlugin } from "./copy.js";
|
|
3
3
|
import { detectAction, isComponentSource } from "./detect.js";
|
|
4
|
+
import { assertPluginSources } from "./validate.js";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import fs from "node:fs/promises";
|
|
6
7
|
//#region src/plugins/manager.ts
|
|
@@ -19,6 +20,7 @@ var PluginManager = class {
|
|
|
19
20
|
nameIndex = /* @__PURE__ */ new Map();
|
|
20
21
|
devMode = false;
|
|
21
22
|
constructor(plugins, cacheDir, ttlMs, devMode = false) {
|
|
23
|
+
assertPluginSources(plugins);
|
|
22
24
|
this.plugins = plugins;
|
|
23
25
|
this.outputDir = path.resolve(process.cwd(), cacheDir ?? DEFAULT_CACHE_DIR);
|
|
24
26
|
this.ttlMs = ttlMs ?? DEFAULT_TTL_MS;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isComponentSource } from "./detect.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
//#region src/plugins/validate.ts
|
|
4
|
+
var isRemote = (js) => js.startsWith("http://") || js.startsWith("https://");
|
|
5
|
+
/** The file a plugin source needs on disk, or null when nothing local is involved — a remote URL (fetched and
|
|
6
|
+
* cached at request time) or a component handed in directly. */
|
|
7
|
+
var localSourcePath = (source) => {
|
|
8
|
+
if (isComponentSource(source) || isRemote(source.js)) return null;
|
|
9
|
+
return source.js;
|
|
10
|
+
};
|
|
11
|
+
/** Fail at boot on a plugin whose entry file is missing. A plugin entry is a plain string path in the server
|
|
12
|
+
* config, so nothing — not tsc, not eslint — notices when that file is moved or deleted; without this check the
|
|
13
|
+
* server starts happily and only dies on the first render that needs the plugin, with an esbuild resolve error
|
|
14
|
+
* far from its cause. */
|
|
15
|
+
var assertPluginSources = (plugins) => {
|
|
16
|
+
const missing = Object.entries(plugins).map(([name, source]) => ({
|
|
17
|
+
name,
|
|
18
|
+
file: localSourcePath(source)
|
|
19
|
+
})).filter((entry) => entry.file !== null && !fs.existsSync(entry.file)).map((entry) => ` ${entry.name}: ${entry.file ?? ""}`);
|
|
20
|
+
if (missing.length > 0) throw new Error(`Plugin entry file not found (declared in the server config):\n${missing.join("\n")}`);
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { assertPluginSources, localSourcePath };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -10,6 +10,9 @@ export interface BaseContext {
|
|
|
10
10
|
res: SSRResponseHelpers;
|
|
11
11
|
config: SSRServerConfig;
|
|
12
12
|
port: number;
|
|
13
|
+
/** Set by the stage that answers when the path alone does not identify the work (the MCP endpoint serves every
|
|
14
|
+
* JSON-RPC method on the same URL). The dispatcher folds it into the access log. */
|
|
15
|
+
operation?: string;
|
|
13
16
|
}
|
|
14
17
|
export interface SSRContext extends BaseContext {
|
|
15
18
|
renderFn: SSRTemplateFn;
|
|
@@ -4,4 +4,9 @@ export declare const parseRequest: (raw: IncomingMessage) => SSRRequest;
|
|
|
4
4
|
/** The public origin the request was addressed to (proxy-aware via x-forwarded-proto, port included), or an empty
|
|
5
5
|
* string when the request carries no usable authority. */
|
|
6
6
|
export declare const requestOrigin: (req: SSRRequest) => string;
|
|
7
|
+
/** The address the request came from, seen through the proxies a deployment sits behind: Cloudflare states the
|
|
8
|
+
* peer it accepted in `cf-connecting-ip`, the ingress in `x-real-ip`, and `x-forwarded-for` lists the chain with
|
|
9
|
+
* the original client first. All three are plain headers a direct client can forge, so this is log/diagnostic
|
|
10
|
+
* material — never an authorisation input. Falls back to the socket peer, which no client controls. */
|
|
11
|
+
export declare const clientIp: (raw: IncomingMessage, req: SSRRequest) => string;
|
|
7
12
|
export declare const readRawBody: (raw: IncomingMessage) => Promise<string>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Stage } from '../http/types';
|
|
2
|
+
/** OAuth 2.1 authorization for the MCP server, mounted ONLY when a deployment configures `oauth`. Without it the
|
|
3
|
+
* stage falls straight through and the server keeps its anonymous contract: discovery 404s, the public surface
|
|
4
|
+
* (handshake, listings, the guide, plitzi_render) answers without a token, and nothing below changes.
|
|
5
|
+
*
|
|
6
|
+
* It sits before the MCP stage because that one answers every path on a dedicated MCP server — these endpoints
|
|
7
|
+
* would otherwise be swallowed by the JSON-RPC transport, which is exactly the 406 a host hits on /register. */
|
|
8
|
+
export declare const oauthStage: Stage;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { SSRResponseHelpers } from '@plitzi/sdk-shared';
|
|
2
2
|
export type RawResponse = {
|
|
3
3
|
headersSent: boolean;
|
|
4
|
+
/** The status actually written to the wire — the access log reads it once the response is out. */
|
|
5
|
+
statusCode: number;
|
|
4
6
|
setHeader(name: string, value: string | number | readonly string[]): unknown;
|
|
5
7
|
getHeaders(): Record<string, string | number | readonly string[]>;
|
|
6
8
|
writeHead(statusCode: number, headers?: Record<string, string | number | readonly string[]>): unknown;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ServerLogEvent, ServerLogger } from '@plitzi/sdk-shared';
|
|
2
|
+
/** One line for any {@link ServerLogEvent}: an HTTP request reads as an access-log line
|
|
3
|
+
* (`[SSR] 203.0.113.7 GET /pricing 200 12ms ok`), the MCP events as what happened inside one
|
|
4
|
+
* (`[mcp] tools/call plitzi_apply {operations:[3]} 41ms ok`). Rendering is a pure format — the dispatcher
|
|
5
|
+
* already stripped query values, collected no headers, cookies or tokens and summarised tool args by shape;
|
|
6
|
+
* the client IP it does carry is personal data, so a sink that persists these lines must say so. */
|
|
7
|
+
export declare const renderLogEvent: (event: ServerLogEvent) => string;
|
|
8
|
+
/** A drop-in `SSRServerConfig.logger` for consumers that just want the log on the console. Consumers with their
|
|
9
|
+
* own logging stack should pass their own sink instead and read the structured event. */
|
|
10
|
+
export declare const consoleLogger: ServerLogger;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createServer, createSSRServer, createMCPServer, resolveServices } from './core/createServer';
|
|
2
2
|
export { registerHealthCheck, buildHealthPayload } from './core/health';
|
|
3
|
+
export { consoleLogger, renderLogEvent } from './helpers/serverLog';
|
|
3
4
|
export type { HealthCheckApp, HealthIdentity } from './core/health';
|
|
4
5
|
export { createJsonAdapters } from './adapters/jsonAdapters';
|
|
5
6
|
export { AIEngine, toolResponseOk, toolResponseErr, zodToJsonSchema, getAllowedModes, bindTools, isToolActive, resolveToolHandler, isCallToolResult, toolResponseFromResult } from './modules/ai';
|
package/dist/src/mcp.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* AI engine…), so pulling `createMCPServer` from there makes the process load that whole graph — including the
|
|
5
5
|
* React element packages its types come from — where this entry loads only the MCP server. */
|
|
6
6
|
export { createMCPServer } from './core/server/mcpServer';
|
|
7
|
+
export { consoleLogger, renderLogEvent } from './helpers/serverLog';
|
|
7
8
|
export { createMcpServer, handleMcp, serveMcp, readMcpBody } from './modules/mcp/handler';
|
|
8
9
|
export { createHttpPreviewClient } from './modules/mcp/previewClient';
|
|
9
10
|
export { createHttpScreenshotClient } from './modules/mcp/screenshotClient';
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { createMcpServer } from './server';
|
|
2
2
|
import { PreviewClient, ScreenshotClient } from './types';
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
-
import { SSRAdapters, SSRRequest,
|
|
4
|
+
import { SSRAdapters, SSRRequest, ServerLogger } from '@plitzi/sdk-shared';
|
|
5
5
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
6
|
/** Per-request wiring the MCP service does not resolve itself: the renderer clients and the log sink. Everything
|
|
7
7
|
* here is optional — the service degrades feature by feature. */
|
|
8
8
|
export type McpRequestOptions = {
|
|
9
9
|
preview?: PreviewClient;
|
|
10
10
|
screenshot?: ScreenshotClient;
|
|
11
|
-
logger?:
|
|
11
|
+
logger?: ServerLogger;
|
|
12
12
|
};
|
|
13
13
|
export declare const readMcpBody: (req: IncomingMessage) => Promise<unknown>;
|
|
14
|
-
export declare const serveMcp: (raw: IncomingMessage, res: ServerResponse, server: McpServer) => Promise<
|
|
15
|
-
export declare const handleMcp: (raw: IncomingMessage, res: ServerResponse, req: SSRRequest, adapters: SSRAdapters, options?: McpRequestOptions) => Promise<
|
|
14
|
+
export declare const serveMcp: (raw: IncomingMessage, res: ServerResponse, server: McpServer) => Promise<string | undefined>;
|
|
15
|
+
export declare const handleMcp: (raw: IncomingMessage, res: ServerResponse, req: SSRRequest, adapters: SSRAdapters, options?: McpRequestOptions) => Promise<string | undefined>;
|
|
16
16
|
export { createMcpServer };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ServerLogger } from '@plitzi/sdk-shared';
|
|
2
2
|
export interface McpLog {
|
|
3
3
|
toolCall(name: string, args: unknown, ms: number, error?: unknown): void;
|
|
4
4
|
resourceRead(uri: string, ms: number, error?: unknown): void;
|
|
5
5
|
}
|
|
6
|
-
/** Build the
|
|
6
|
+
/** Build the protocol-log sink for one MCP server. Dispatches structured events to the consumer's `logger` when
|
|
7
7
|
* provided; otherwise renders to the console when MCP_DEBUG=1; otherwise a no-op. */
|
|
8
|
-
export declare const createMcpLog: (logger?:
|
|
8
|
+
export declare const createMcpLog: (logger?: ServerLogger) => McpLog;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import { McpLog } from '../helpers';
|
|
1
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
export declare const RENDER_GUIDE_URI = "plitzi://render/guide";
|
|
3
4
|
export declare const RENDER_TYPES_URI = "plitzi://render/types";
|
|
4
|
-
export declare const registerRenderResources: (server: McpServer) => void;
|
|
5
|
+
export declare const registerRenderResources: (server: McpServer, log: McpLog) => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { PreviewClient, ScreenshotClient } from './types';
|
|
3
|
-
import { SSRAdapters,
|
|
3
|
+
import { SSRAdapters, ServerLogger } from '@plitzi/sdk-shared';
|
|
4
4
|
/** The MCP service is stateless: every request resolves its own `spaceId` (from the request JWT) and reads the
|
|
5
5
|
* space fresh through the adapters — schema and style are two documents, read/written independently. Both the
|
|
6
6
|
* spaceId and the space itself resolve lazily, so the public surface (handshake, tools-list, resources-list,
|
|
@@ -17,6 +17,6 @@ export interface McpServerContext {
|
|
|
17
17
|
screenshot?: ScreenshotClient;
|
|
18
18
|
/** Structured request-log sink. When set, every tool call and resource read emits an McpLogEvent to it (the
|
|
19
19
|
* consumer renders them); otherwise logging falls back to the console when MCP_DEBUG=1. */
|
|
20
|
-
logger?:
|
|
20
|
+
logger?: ServerLogger;
|
|
21
21
|
}
|
|
22
22
|
export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger }: McpServerContext) => McpServer;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { OAuthParams } from './params';
|
|
2
|
+
import { OAuthConfig, SSRResponseHelpers } from '@plitzi/sdk-shared';
|
|
3
|
+
/** GET /authorize — the entry point a host opens in the user's browser. */
|
|
4
|
+
export declare const handleAuthorizeStart: (config: OAuthConfig, res: SSRResponseHelpers, params: OAuthParams) => Promise<void>;
|
|
5
|
+
/** POST /authorize — both steps of the form land here: the credentials submit, and the grant submit that carries
|
|
6
|
+
* the `pending` id proving the credentials step already passed. */
|
|
7
|
+
export declare const handleAuthorizeSubmit: (config: OAuthConfig, res: SSRResponseHelpers, params: OAuthParams) => Promise<void>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { OAuthConsentView } from '@plitzi/sdk-shared';
|
|
2
|
+
/** The built-in consent screen: a credentials step, then a step to pick what the client gets access to. Replace it
|
|
3
|
+
* wholesale via `oauth.renderConsent` — the field names read back here are the contract, not the markup. */
|
|
4
|
+
export declare const renderConsentPage: (view: OAuthConsentView) => string;
|
|
5
|
+
/** The dead end for a request that cannot be redirected back — a bad `redirect_uri` or an unknown client. Bouncing
|
|
6
|
+
* those to the client would make this server an open redirector, so the user is told here instead. */
|
|
7
|
+
export declare const renderErrorPage: (title: string, detail: string) => string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { OAuthConfig, SSRRequest } from '@plitzi/sdk-shared';
|
|
2
|
+
export declare const AUTHORIZE_PATH = "/authorize";
|
|
3
|
+
export declare const TOKEN_PATH = "/token";
|
|
4
|
+
export declare const REGISTER_PATH = "/register";
|
|
5
|
+
export declare const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
|
6
|
+
export declare const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
|
|
7
|
+
/** The identifier the two documents agree on. This server is both the protected resource and its own
|
|
8
|
+
* authorization server, so one origin names both. Derived from the request unless pinned, which keeps a
|
|
9
|
+
* deployment correct across its dev, staging and production hosts without per-environment config. */
|
|
10
|
+
export declare const issuerOf: (config: OAuthConfig, req: SSRRequest) => string;
|
|
11
|
+
export declare const scopesOf: (config: OAuthConfig) => string[];
|
|
12
|
+
/** 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`. */
|
|
14
|
+
export declare const protectedResourceMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
|
|
15
|
+
/** 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. */
|
|
17
|
+
export declare const authorizationServerMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Query-string or form fields as they arrive from a client: every name is optional, and an absent one is not
|
|
2
|
+
* distinguishable from an empty one — both mean "the client did not send this". */
|
|
3
|
+
export type OAuthParams = Record<string, string | undefined>;
|
|
4
|
+
export declare const field: (params: OAuthParams, name: string) => string;
|
|
5
|
+
export declare const optionalField: (params: OAuthParams, name: string) => string | undefined;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** An unguessable, URL-safe identifier for the values this layer hands out (codes, refresh tokens, client ids).
|
|
2
|
+
* 256 bits from the CSPRNG, so they are also unguessable by anyone who collects a few of them. */
|
|
3
|
+
export declare const randomId: () => string;
|
|
4
|
+
/** RFC 7636 S256, the only method this server accepts — `plain` puts the verifier on the wire, and a public
|
|
5
|
+
* client on a desktop host has no other proof it is the one that started the flow. */
|
|
6
|
+
export declare const verifyChallenge: (verifier: string, challenge: string) => boolean;
|