@plitzi/sdk-server 0.32.11 → 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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.32.12
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.32.12
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.32.12
10
+ - @plitzi/sdk-schema@0.32.12
11
+ - @plitzi/sdk-shared@0.32.12
12
+
3
13
  ## 0.32.11
4
14
 
5
15
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  import { applySecurityHeaders } from "./securityHeaders.js";
2
2
  import { buildResponseHelpers } from "../../helpers/buildResponseHelpers.js";
3
- import { parseRequest } from "../requestParser.js";
3
+ import { clientIp, parseRequest } from "../requestParser.js";
4
4
  //#region src/core/http/dispatcher.ts
5
5
  var safePath = (url) => {
6
6
  const [path = "/", query] = url.split("?");
@@ -16,6 +16,7 @@ var runPipeline = async (raw, rawRes, buildContext, stages, server) => {
16
16
  const res = buildResponseHelpers(rawRes, req.headers["accept-encoding"]);
17
17
  const ctx = buildContext(raw, rawRes, req, res);
18
18
  const logger = ctx.config.logger;
19
+ const ip = logger ? clientIp(raw, req) : "";
19
20
  const logRequest = (error) => {
20
21
  if (!logger) return;
21
22
  const status = statusOf(rawRes, res);
@@ -24,6 +25,7 @@ var runPipeline = async (raw, rawRes, buildContext, stages, server) => {
24
25
  server,
25
26
  method: req.method,
26
27
  path: safePath(req.url),
28
+ ...ip ? { clientIp: ip } : {},
27
29
  ...ctx.operation === void 0 ? {} : { operation: ctx.operation },
28
30
  status,
29
31
  durationMs: Date.now() - startedAt,
@@ -30,6 +30,23 @@ var parseRequest = (raw) => {
30
30
  ctx: {}
31
31
  };
32
32
  };
33
+ var AUTHORITY_RE = /^[a-zA-Z0-9.-]{1,253}(?::\d{1,5})?$/u;
34
+ /** The public origin the request was addressed to (proxy-aware via x-forwarded-proto, port included), or an empty
35
+ * string when the request carries no usable authority. */
36
+ var requestOrigin = (req) => {
37
+ const authority = req.headers[":authority"] ?? req.headers["host"] ?? "";
38
+ return AUTHORITY_RE.test(authority) ? `${req.protocol}://${authority}` : "";
39
+ };
40
+ var headerValue = (value) => (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
41
+ var unmapIpv4 = (address) => address.startsWith("::ffff:") && address.includes(".") ? address.slice(7) : address;
42
+ /** The address the request came from, seen through the proxies a deployment sits behind: Cloudflare states the
43
+ * peer it accepted in `cf-connecting-ip`, the ingress in `x-real-ip`, and `x-forwarded-for` lists the chain with
44
+ * the original client first. All three are plain headers a direct client can forge, so this is log/diagnostic
45
+ * material — never an authorisation input. Falls back to the socket peer, which no client controls. */
46
+ var clientIp = (raw, req) => {
47
+ const forwardedFor = headerValue(req.headers["x-forwarded-for"]).split(",")[0] ?? "";
48
+ return unmapIpv4(headerValue(req.headers["cf-connecting-ip"]) || headerValue(req.headers["x-real-ip"]) || forwardedFor.trim() || raw.socket.remoteAddress || "");
49
+ };
33
50
  var MAX_BODY_BYTES = 1024 * 1024;
34
51
  var readRawBody = (raw) => new Promise((resolve, reject) => {
35
52
  const chunks = [];
@@ -46,4 +63,4 @@ var readRawBody = (raw) => new Promise((resolve, reject) => {
46
63
  raw.on("error", reject);
47
64
  });
48
65
  //#endregion
49
- export { parseRequest, readRawBody };
66
+ export { clientIp, parseRequest, readRawBody, requestOrigin };
@@ -0,0 +1,71 @@
1
+ import { readRawBody } from "../requestParser.js";
2
+ import { authorizationServerMetadata, protectedResourceMetadata } from "../../modules/oauth/metadata.js";
3
+ import { sendErrorJson, sendJson } from "../../modules/oauth/respond.js";
4
+ import { handleAuthorizeStart, handleAuthorizeSubmit } from "../../modules/oauth/authorize.js";
5
+ import { handleRegister } from "../../modules/oauth/register.js";
6
+ import { handleToken } from "../../modules/oauth/token.js";
7
+ //#region src/core/services/oauth.ts
8
+ var matchesWellKnown = (path, wellKnown) => path === wellKnown || path.startsWith(`${wellKnown}/`);
9
+ var isOAuthPath = (path) => matchesWellKnown(path, "/.well-known/oauth-protected-resource") || matchesWellKnown(path, "/.well-known/oauth-authorization-server") || path === "/register" || path === "/authorize" || path === "/token";
10
+ var formParams = (req, body) => {
11
+ const params = { ...req.query };
12
+ for (const [key, value] of new URLSearchParams(body).entries()) params[key] = value;
13
+ return params;
14
+ };
15
+ var parseJson = (body) => {
16
+ try {
17
+ return JSON.parse(body);
18
+ } catch {
19
+ return;
20
+ }
21
+ };
22
+ /** OAuth 2.1 authorization for the MCP server, mounted ONLY when a deployment configures `oauth`. Without it the
23
+ * stage falls straight through and the server keeps its anonymous contract: discovery 404s, the public surface
24
+ * (handshake, listings, the guide, plitzi_render) answers without a token, and nothing below changes.
25
+ *
26
+ * It sits before the MCP stage because that one answers every path on a dedicated MCP server — these endpoints
27
+ * would otherwise be swallowed by the JSON-RPC transport, which is exactly the 406 a host hits on /register. */
28
+ var oauthStage = async (ctx) => {
29
+ const { oauth } = ctx.config;
30
+ const { path, method } = ctx.req;
31
+ if (!oauth || !isOAuthPath(path)) return false;
32
+ const { res } = ctx;
33
+ if (path !== "/authorize") {
34
+ res.setHeader("Access-Control-Allow-Origin", "*");
35
+ res.setHeader("Access-Control-Allow-Headers", "*");
36
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
37
+ }
38
+ if (method === "OPTIONS") {
39
+ res.setStatus(204);
40
+ res.end();
41
+ return true;
42
+ }
43
+ if (method === "GET" && matchesWellKnown(path, "/.well-known/oauth-protected-resource")) {
44
+ sendJson(res, 200, protectedResourceMetadata(oauth, ctx.req));
45
+ return true;
46
+ }
47
+ if (method === "GET" && matchesWellKnown(path, "/.well-known/oauth-authorization-server")) {
48
+ sendJson(res, 200, authorizationServerMetadata(oauth, ctx.req));
49
+ return true;
50
+ }
51
+ if (path === "/authorize" && method === "GET") {
52
+ await handleAuthorizeStart(oauth, res, ctx.req.query);
53
+ return true;
54
+ }
55
+ if (path === "/authorize" && method === "POST") {
56
+ await handleAuthorizeSubmit(oauth, res, formParams(ctx.req, await readRawBody(ctx.raw)));
57
+ return true;
58
+ }
59
+ if (path === "/token" && method === "POST") {
60
+ await handleToken(oauth, res, formParams(ctx.req, await readRawBody(ctx.raw)));
61
+ return true;
62
+ }
63
+ if (path === "/register" && method === "POST") {
64
+ await handleRegister(oauth, res, parseJson(await readRawBody(ctx.raw)));
65
+ return true;
66
+ }
67
+ sendErrorJson(res, 405, "invalid_request", `${method} is not allowed on ${path}.`);
68
+ return true;
69
+ };
70
+ //#endregion
71
+ export { oauthStage };
@@ -1,4 +1,5 @@
1
1
  import { mcpOnlyStage, mcpStage } from "./mcp.js";
2
+ import { oauthStage } from "./oauth.js";
2
3
  import { previewStage } from "./preview.js";
3
4
  import { rscStage } from "./rsc.js";
4
5
  import { notFoundStage, ssrStage } from "./ssr.js";
@@ -28,6 +29,7 @@ var buildSSRPipeline = (services) => {
28
29
  var buildMCPPipeline = () => [
29
30
  healthStage,
30
31
  configStaticStage,
32
+ oauthStage,
31
33
  mcpOnlyStage
32
34
  ];
33
35
  //#endregion
@@ -4,6 +4,7 @@ var rscStage = async (ctx) => {
4
4
  const { config, req } = ctx;
5
5
  const rscPath = config.rsc?.path ?? "/_rsc";
6
6
  if (!(config.rsc?.enabled ?? true) || req.method !== "GET" || req.path !== rscPath) return false;
7
+ ctx.operation = "rsc";
7
8
  await handleRsc(req, ctx.res, config, ctx.pluginManager, ctx.caches.rsc);
8
9
  return true;
9
10
  };
@@ -1,9 +1,10 @@
1
1
  //#region src/helpers/serverLog.ts
2
2
  var outcomeOf = (event) => event.ok ? "ok" : `ERROR ${event.error ?? ""}`.trim();
3
3
  var renderRequest = (event) => {
4
+ const client = event.clientIp ? `${event.clientIp} ` : "";
4
5
  const operation = event.operation ? ` ${event.operation}` : "";
5
6
  const timing = `${Math.round(event.durationMs)}ms`;
6
- return `[${event.server}] ${event.method} ${event.path}${operation} ${event.status} ${timing} ${outcomeOf(event)}`;
7
+ return `[${event.server}] ${client}${event.method} ${event.path}${operation} ${event.status} ${timing} ${outcomeOf(event)}`;
7
8
  };
8
9
  var renderTool = (event) => {
9
10
  const args = event.argsSummary ? ` ${event.argsSummary}` : "";
@@ -11,10 +12,10 @@ var renderTool = (event) => {
11
12
  };
12
13
  var renderResource = (event) => `[mcp] resources/read ${event.name} ${Math.round(event.durationMs)}ms ${outcomeOf(event)}`;
13
14
  /** One line for any {@link ServerLogEvent}: an HTTP request reads as an access-log line
14
- * (`[SSR] GET /pricing 200 12ms ok`), the MCP events as what happened inside one
15
- * (`[mcp] tools/call plitzi_apply {operations:[3]} 41ms ok`). Events arrive PII-free — the dispatcher strips
16
- * query values and collects no headers, cookies or IPs, and tool args are summarised by shape — so rendering is
17
- * a pure format. */
15
+ * (`[SSR] 203.0.113.7 GET /pricing 200 12ms ok`), the MCP events as what happened inside one
16
+ * (`[mcp] tools/call plitzi_apply {operations:[3]} 41ms ok`). Rendering is a pure format — the dispatcher
17
+ * already stripped query values, collected no headers, cookies or tokens and summarised tool args by shape;
18
+ * the client IP it does carry is personal data, so a sink that persists these lines must say so. */
18
19
  var renderLogEvent = (event) => {
19
20
  switch (event.kind) {
20
21
  case "request": return renderRequest(event);
@@ -34,6 +34,11 @@ var serveMcp = async (raw, res, server) => {
34
34
  res.end();
35
35
  return;
36
36
  }
37
+ if (raw.url === "/favicon.ico") {
38
+ res.writeHead(204);
39
+ res.end();
40
+ return;
41
+ }
37
42
  if (raw.method !== "POST") {
38
43
  res.writeHead(405, {
39
44
  "Content-Type": "application/json",
@@ -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.11"
46
+ version: "0.32.12"
47
47
  }, { instructions: serverInstructions });
48
48
  registerResources(server, getSpace, MCP_ENV, log);
49
49
  registerApps(server);
@@ -0,0 +1,160 @@
1
+ import { renderConsentPage } from "./consentPage.js";
2
+ import { AUTHORIZE_PATH } from "./metadata.js";
3
+ import { field, optionalField } from "./params.js";
4
+ import { randomId } from "./pkce.js";
5
+ import { dropPending, getClient, getPending, putCode, putPending } from "./records.js";
6
+ import { redirectWithCode, redirectWithError, sendErrorPage, sendHtml } from "./respond.js";
7
+ //#region src/modules/oauth/authorize.ts
8
+ var DEFAULT_CODE_TTL_SECONDS = 60;
9
+ var hiddenFieldsFor = (request, pendingId) => {
10
+ const hidden = {
11
+ client_id: request.clientId,
12
+ redirect_uri: request.redirectUri,
13
+ code_challenge: request.challenge,
14
+ code_challenge_method: "S256"
15
+ };
16
+ if (request.state !== void 0) hidden["state"] = request.state;
17
+ if (request.scope !== void 0) hidden["scope"] = request.scope;
18
+ if (pendingId !== void 0) hidden["pending"] = pendingId;
19
+ return hidden;
20
+ };
21
+ var renderConsent = async (config, res, view) => {
22
+ sendHtml(res, 200, await (config.renderConsent ?? renderConsentPage)(view));
23
+ };
24
+ /** Validates the parts of an authorization request that decide WHERE a failure may be reported. Until the client
25
+ * and its redirect target check out, nothing may be sent back to the client. */
26
+ var resolveRequest = async (config, res, params) => {
27
+ const clientId = field(params, "client_id");
28
+ const redirectUri = field(params, "redirect_uri");
29
+ const client = clientId ? await getClient(config.adapters.store, clientId) : void 0;
30
+ if (!client) {
31
+ sendErrorPage(res, "Unknown client", "This application is not registered with the server, or its registration expired.");
32
+ return;
33
+ }
34
+ if (!redirectUri || !client.redirectUris.includes(redirectUri)) {
35
+ sendErrorPage(res, "Invalid redirect", "The application asked to be sent back to an address it did not register.");
36
+ return;
37
+ }
38
+ const state = optionalField(params, "state");
39
+ const responseType = field(params, "response_type");
40
+ if (responseType && responseType !== "code") {
41
+ redirectWithError(res, redirectUri, "unsupported_response_type", "Only the authorization code flow is supported.", state);
42
+ return;
43
+ }
44
+ const challenge = field(params, "code_challenge");
45
+ const method = field(params, "code_challenge_method");
46
+ if (!challenge || method !== "S256") {
47
+ redirectWithError(res, redirectUri, "invalid_request", "PKCE with code_challenge_method=S256 is required.", state);
48
+ return;
49
+ }
50
+ return {
51
+ clientId,
52
+ redirectUri,
53
+ challenge,
54
+ state,
55
+ scope: optionalField(params, "scope")
56
+ };
57
+ };
58
+ /** Consent granted: mint the bearer now, park it behind a one-shot code and send the browser back. Minting here
59
+ * 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);
62
+ if (!issued) {
63
+ redirectWithError(res, request.redirectUri, "access_denied", "The account may not grant access to this resource.", request.state);
64
+ return;
65
+ }
66
+ const code = randomId();
67
+ await putCode(config.adapters.store, code, {
68
+ clientId: request.clientId,
69
+ redirectUri: request.redirectUri,
70
+ challenge: request.challenge,
71
+ token: issued.token,
72
+ expiresInSeconds: issued.expiresInSeconds,
73
+ scope: request.scope,
74
+ user: pending.user,
75
+ target
76
+ }, config.codeTtlSeconds ?? DEFAULT_CODE_TTL_SECONDS);
77
+ redirectWithCode(res, request.redirectUri, code, request.state);
78
+ };
79
+ /** GET /authorize — the entry point a host opens in the user's browser. */
80
+ var handleAuthorizeStart = async (config, res, params) => {
81
+ const request = await resolveRequest(config, res, params);
82
+ if (!request) return;
83
+ await renderConsent(config, res, {
84
+ step: "credentials",
85
+ action: AUTHORIZE_PATH,
86
+ hidden: hiddenFieldsFor(request),
87
+ targets: [],
88
+ branding: config.branding ?? {}
89
+ });
90
+ };
91
+ /** POST /authorize — both steps of the form land here: the credentials submit, and the grant submit that carries
92
+ * the `pending` id proving the credentials step already passed. */
93
+ var handleAuthorizeSubmit = async (config, res, params) => {
94
+ const request = await resolveRequest(config, res, params);
95
+ if (!request) return;
96
+ const pendingId = optionalField(params, "pending");
97
+ if (pendingId) {
98
+ const pending = await getPending(config.adapters.store, pendingId);
99
+ if (!pending || pending.clientId !== request.clientId) {
100
+ redirectWithError(res, request.redirectUri, "access_denied", "The sign-in expired. Try connecting again.", request.state);
101
+ return;
102
+ }
103
+ const targets = await config.adapters.grantTargets(pending.user);
104
+ const chosen = targets.find((target) => target.value === field(params, "target"));
105
+ if (!chosen) {
106
+ await renderConsent(config, res, {
107
+ step: "target",
108
+ action: AUTHORIZE_PATH,
109
+ hidden: hiddenFieldsFor(request, pendingId),
110
+ targets,
111
+ user: pending.user,
112
+ error: "Choose what to grant access to.",
113
+ branding: config.branding ?? {}
114
+ });
115
+ return;
116
+ }
117
+ await dropPending(config.adapters.store, pendingId);
118
+ await completeGrant(config, res, request, pending, chosen);
119
+ return;
120
+ }
121
+ const user = await config.adapters.authenticate({
122
+ username: field(params, "username"),
123
+ password: field(params, "password")
124
+ });
125
+ if (!user) {
126
+ await renderConsent(config, res, {
127
+ step: "credentials",
128
+ action: AUTHORIZE_PATH,
129
+ hidden: hiddenFieldsFor(request),
130
+ targets: [],
131
+ error: "Those credentials did not match an account.",
132
+ branding: config.branding ?? {}
133
+ });
134
+ return;
135
+ }
136
+ const targets = await config.adapters.grantTargets(user);
137
+ if (targets.length === 0) {
138
+ redirectWithError(res, request.redirectUri, "access_denied", "This account has nothing to grant access to.", request.state);
139
+ return;
140
+ }
141
+ const nextPendingId = randomId();
142
+ await putPending(config.adapters.store, nextPendingId, {
143
+ clientId: request.clientId,
144
+ redirectUri: request.redirectUri,
145
+ challenge: request.challenge,
146
+ state: request.state,
147
+ scope: request.scope,
148
+ user
149
+ });
150
+ await renderConsent(config, res, {
151
+ step: "target",
152
+ action: AUTHORIZE_PATH,
153
+ hidden: hiddenFieldsFor(request, nextPendingId),
154
+ targets,
155
+ user,
156
+ branding: config.branding ?? {}
157
+ });
158
+ };
159
+ //#endregion
160
+ export { handleAuthorizeStart, handleAuthorizeSubmit };
@@ -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 "&amp;";
5
+ case "<": return "&lt;";
6
+ case ">": return "&gt;";
7
+ case "\"": return "&quot;";
8
+ default: return "&#39;";
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,5 @@
1
+ //#region src/modules/oauth/params.ts
2
+ var field = (params, name) => params[name] ?? "";
3
+ var optionalField = (params, name) => params[name] || void 0;
4
+ //#endregion
5
+ export { field, optionalField };
@@ -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 };
@@ -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,9 +1,9 @@
1
1
  import { ServerLogEvent, ServerLogger } from '@plitzi/sdk-shared';
2
2
  /** One line for any {@link ServerLogEvent}: an HTTP request reads as an access-log line
3
- * (`[SSR] GET /pricing 200 12ms ok`), the MCP events as what happened inside one
4
- * (`[mcp] tools/call plitzi_apply {operations:[3]} 41ms ok`). Events arrive PII-free — the dispatcher strips
5
- * query values and collects no headers, cookies or IPs, and tool args are summarised by shape — so rendering is
6
- * a pure format. */
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
7
  export declare const renderLogEvent: (event: ServerLogEvent) => string;
8
8
  /** A drop-in `SSRServerConfig.logger` for consumers that just want the log on the console. Consumers with their
9
9
  * own logging stack should pass their own sink instead and read the structured event. */
@@ -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;
@@ -0,0 +1,49 @@
1
+ import { OAuthGrantTarget, OAuthStore, OAuthUser } from '@plitzi/sdk-shared';
2
+ /** A client that registered itself through RFC 7591. Public clients only — a desktop host cannot keep a secret,
3
+ * so the token endpoint authenticates the exchange with PKCE instead. */
4
+ export type ClientRecord = {
5
+ clientId: string;
6
+ clientName: string;
7
+ redirectUris: string[];
8
+ };
9
+ /** The authorization request, parked while the user logs in. `challenge` binds the eventual code to the client
10
+ * that started the flow. */
11
+ export type PendingRecord = {
12
+ clientId: string;
13
+ redirectUri: string;
14
+ challenge: string;
15
+ state?: string;
16
+ scope?: string;
17
+ user: OAuthUser;
18
+ };
19
+ /** A code, redeemable once, for the token it already resolves to. Minting the bearer at consent time (rather than
20
+ * at redemption) means a failure the user could act on — no space, revoked access — surfaces on the consent
21
+ * screen instead of as an opaque `invalid_grant` inside the host. */
22
+ export type CodeRecord = {
23
+ clientId: string;
24
+ redirectUri: string;
25
+ challenge: string;
26
+ token: string;
27
+ expiresInSeconds?: number;
28
+ scope?: string;
29
+ user: OAuthUser;
30
+ target: OAuthGrantTarget;
31
+ };
32
+ /** A refresh grant: everything needed to mint a fresh bearer without asking the user again. */
33
+ export type RefreshRecord = {
34
+ clientId: string;
35
+ scope?: string;
36
+ user: OAuthUser;
37
+ target: OAuthGrantTarget;
38
+ };
39
+ export declare const putClient: (store: OAuthStore, client: ClientRecord) => Promise<void>;
40
+ export declare const getClient: (store: OAuthStore, clientId: string) => Promise<ClientRecord | undefined>;
41
+ export declare const putPending: (store: OAuthStore, id: string, pending: PendingRecord) => Promise<void>;
42
+ export declare const getPending: (store: OAuthStore, id: string) => Promise<PendingRecord | undefined>;
43
+ export declare const dropPending: (store: OAuthStore, id: string) => Promise<void>;
44
+ export declare const putCode: (store: OAuthStore, code: string, record: CodeRecord, ttlSeconds: number) => Promise<void>;
45
+ export declare const getCode: (store: OAuthStore, code: string) => Promise<CodeRecord | undefined>;
46
+ export declare const dropCode: (store: OAuthStore, code: string) => Promise<void>;
47
+ export declare const putRefresh: (store: OAuthStore, token: string, record: RefreshRecord, ttlSeconds: number) => Promise<void>;
48
+ export declare const getRefresh: (store: OAuthStore, token: string) => Promise<RefreshRecord | undefined>;
49
+ export declare const dropRefresh: (store: OAuthStore, token: string) => Promise<void>;
@@ -0,0 +1,5 @@
1
+ import { OAuthConfig, SSRResponseHelpers } from '@plitzi/sdk-shared';
2
+ /** RFC 7591 dynamic client registration. A remote host has no way to be configured into this server ahead of
3
+ * time, so it registers itself on first connect; the record it gets back is only ever used to pin the redirect
4
+ * target, since a public client authenticates with PKCE rather than with credentials. */
5
+ export declare const handleRegister: (config: OAuthConfig, res: SSRResponseHelpers, body: unknown) => Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { SSRResponseHelpers } from '@plitzi/sdk-shared';
2
+ /** The error codes RFC 6749 defines for the responses this server produces. */
3
+ export type OAuthErrorCode = 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'unsupported_response_type' | 'invalid_scope' | 'access_denied' | 'server_error';
4
+ export declare const sendJson: (res: SSRResponseHelpers, status: number, body: unknown) => void;
5
+ export declare const sendHtml: (res: SSRResponseHelpers, status: number, html: string) => void;
6
+ export declare const sendErrorJson: (res: SSRResponseHelpers, status: number, error: OAuthErrorCode, description: string) => void;
7
+ /** Shown when the authorization request itself is unusable — an unknown client, a `redirect_uri` that is not
8
+ * registered. Redirecting those back would turn the endpoint into an open redirector. */
9
+ export declare const sendErrorPage: (res: SSRResponseHelpers, title: string, detail: string) => void;
10
+ /** The other half of RFC 6749 error handling: once the redirect target is known to be legitimate, failures go
11
+ * back to the client so the host can report them, carrying `state` so it can match the response to its request. */
12
+ export declare const redirectWithError: (res: SSRResponseHelpers, redirectUri: string, error: OAuthErrorCode, description: string, state?: string) => void;
13
+ export declare const redirectWithCode: (res: SSRResponseHelpers, redirectUri: string, code: string, state?: string) => void;
@@ -0,0 +1,5 @@
1
+ import { OAuthParams } from './params';
2
+ import { OAuthConfig, SSRResponseHelpers } from '@plitzi/sdk-shared';
3
+ /** POST /token. Public clients only, so there is no client authentication to check — PKCE is what proves the
4
+ * caller is the one that started the flow. */
5
+ export declare const handleToken: (config: OAuthConfig, res: SSRResponseHelpers, params: OAuthParams) => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.32.11",
3
+ "version": "0.32.12",
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.11",
33
- "@plitzi/sdk-schema": "0.32.11",
34
- "@plitzi/sdk-shared": "0.32.11",
32
+ "@plitzi/plitzi-sdk": "0.32.12",
33
+ "@plitzi/sdk-schema": "0.32.12",
34
+ "@plitzi/sdk-shared": "0.32.12",
35
35
  "ejs": "^6.0.1",
36
36
  "esbuild": "^0.28.1",
37
37
  "zod": "^4.4.3"