@stelstone/server 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The CMS request handler — the single implementation of request policy.
3
+ *
4
+ * Everything that decides *whether* and *how* a request runs lives here:
5
+ * route matching, CORS, authentication, role checks, body parsing, and the
6
+ * ResponseSpec → Response translation. Runtimes (Node, Cloudflare Workers)
7
+ * only translate transport; they never make these decisions.
8
+ *
9
+ * This exists because the previous design had one policy implementation per
10
+ * runtime. They drifted, and both of the security defects found in the
11
+ * 2026-08 audit lived in that drift: a `verify()` that was not awaited in one
12
+ * shim, and an admin-role check that passed unauthenticated requests in the
13
+ * other. With a single chain, that class of bug cannot recur.
14
+ *
15
+ * The handler speaks the Web Fetch API:
16
+ *
17
+ * handle(request, env) -> Response | null
18
+ *
19
+ * `null` means "no CMS route matched" so the caller can fall through to its
20
+ * own handling (static files in Node, the assets binding in a Worker).
21
+ */
22
+
23
+ import { allRoutes } from "../routes.mjs";
24
+
25
+ /** Turn "/api/collections/:collection/:file" into a matcher. */
26
+ function compileRoute(route) {
27
+ const paramNames = [];
28
+ const pattern = route.path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => {
29
+ paramNames.push(name);
30
+ return "([^/]+)";
31
+ });
32
+ return { ...route, regex: new RegExp(`^${pattern}$`), paramNames };
33
+ }
34
+
35
+ function decodeParam(value) {
36
+ try {
37
+ return decodeURIComponent(value);
38
+ } catch {
39
+ return value;
40
+ }
41
+ }
42
+
43
+ /** Query string → plain object (last value wins, matching the previous shims). */
44
+ function queryOf(url) {
45
+ const query = {};
46
+ for (const [key, value] of url.searchParams) query[key] = value;
47
+ return query;
48
+ }
49
+
50
+ /** ResponseSpec ({ status?, json?, text?, redirect?, headers? }) → Response. */
51
+ function toResponse(spec, baseHeaders) {
52
+ if (!spec) return new Response(null, { status: 204, headers: baseHeaders });
53
+
54
+ const headers = new Headers(baseHeaders);
55
+ if (spec.headers) {
56
+ for (const [key, value] of Object.entries(spec.headers)) headers.set(key, value);
57
+ }
58
+
59
+ if (spec.redirect) {
60
+ headers.set("Location", spec.redirect);
61
+ return new Response(null, { status: spec.status || 302, headers });
62
+ }
63
+
64
+ const status = spec.status || 200;
65
+ if (spec.json !== undefined) {
66
+ headers.set("Content-Type", "application/json");
67
+ return new Response(JSON.stringify(spec.json), { status, headers });
68
+ }
69
+ if (spec.text !== undefined) {
70
+ headers.set("Content-Type", "text/plain; charset=utf-8");
71
+ return new Response(spec.text, { status, headers });
72
+ }
73
+ return new Response(null, { status, headers });
74
+ }
75
+
76
+ /**
77
+ * @param {Object} opts
78
+ * @param {Object} opts.config The site's cms.config object.
79
+ * @param {Object|Function} opts.adapters Adapter bag, or (env) => bag
80
+ * when adapters are per-request
81
+ * (Cloudflare Workers).
82
+ * @param {"node"|"worker"} [opts.runtime="node"] Declared by the caller —
83
+ * never sniffed. With `nodejs_compat` a Worker also exposes
84
+ * `process.versions.node`, so feature detection gets this wrong.
85
+ * @param {string|null} [opts.adminUiVersion] Version of the admin UI this
86
+ * deployment serves, when the caller resolved one. Reported by
87
+ * `/api/about`; null when the SPA is served by something the CMS
88
+ * cannot inspect (a Worker's assets binding).
89
+ * @returns {(request: Request, env?: object) => Promise<Response|null>}
90
+ */
91
+ export function createRequestHandler({ config, adapters, runtime = "node", adminUiVersion = null }) {
92
+ const routes = allRoutes(config).map(compileRoute);
93
+ const resolveAdapters = typeof adapters === "function" ? adapters : () => adapters;
94
+
95
+ // Reporting an open CORS policy is config validation's job, not the
96
+ // handler's — see core/config-schema.mjs.
97
+ const corsOrigin = config.cors?.origin ?? "*";
98
+ const corsAllowList = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
99
+
100
+ function corsHeaders(request) {
101
+ const headers = new Headers({
102
+ "Access-Control-Allow-Methods": "GET,PUT,POST,DELETE,OPTIONS",
103
+ "Access-Control-Allow-Headers": "Content-Type,Authorization",
104
+ });
105
+ const origin = request.headers.get("origin");
106
+ if (corsAllowList.includes("*")) {
107
+ headers.set("Access-Control-Allow-Origin", "*");
108
+ } else if (origin && corsAllowList.includes(origin)) {
109
+ headers.set("Access-Control-Allow-Origin", origin);
110
+ headers.set("Vary", "Origin");
111
+ }
112
+ return headers;
113
+ }
114
+
115
+ /** Does any route own this path, regardless of method? (for 405 vs fall-through) */
116
+ function pathIsRouted(pathname) {
117
+ return routes.some((route) => route.regex.test(pathname));
118
+ }
119
+
120
+ return async function handle(request, env) {
121
+ const url = new URL(request.url);
122
+ const pathname = url.pathname;
123
+ const cors = corsHeaders(request);
124
+
125
+ // Preflight for any path this handler owns.
126
+ if (request.method === "OPTIONS" && pathIsRouted(pathname)) {
127
+ return new Response(null, { status: 204, headers: cors });
128
+ }
129
+
130
+ const match = routes
131
+ .map((route) => ({ route, m: route.method === request.method && route.regex.exec(pathname) }))
132
+ .find((candidate) => candidate.m);
133
+
134
+ // Not ours — let the caller serve static files / assets / next middleware.
135
+ if (!match) return null;
136
+
137
+ const { route, m } = match;
138
+ const adapterBag = resolveAdapters(env);
139
+
140
+ // ── Authentication ───────────────────────────────────────────────────
141
+ // One code path, always awaited. `verify` receives the Request so that
142
+ // adapters which authenticate from something other than the Authorization
143
+ // header (e.g. Cloudflare Access, which reads its own header or cookie)
144
+ // are first-class rather than special cases.
145
+ let user = null;
146
+ if (route.auth !== "public") {
147
+ if (!adapterBag.auth.configured) {
148
+ // No credentials configured at all — the local dev mode. Every adapter
149
+ // behaves the same here because the decision is made once, here,
150
+ // rather than inside each adapter's verify(). The server warns loudly
151
+ // about this at startup.
152
+ user = { login: "admin", name: "admin", role: "admin" };
153
+ } else {
154
+ user = await adapterBag.auth.verify(request);
155
+ if (!user) {
156
+ const body =
157
+ config.auth?.provider === "github-oauth"
158
+ ? { error: "Authentication required", loginUrl: "/admin/oauth/login" }
159
+ : { error: "Authentication required" };
160
+ return toResponse({ status: 401, json: body }, cors);
161
+ }
162
+ }
163
+ }
164
+
165
+ // ── Role check ───────────────────────────────────────────────────────
166
+ // Reached only with a resolved user: unauthenticated requests to a
167
+ // non-public route were already rejected above.
168
+ if (route.auth === "admin" && user.role !== "admin") {
169
+ return toResponse({ status: 403, json: { error: "Admin role required" } }, cors);
170
+ }
171
+
172
+ // ── Body ─────────────────────────────────────────────────────────────
173
+ // A route with `rawBody` reads the stream itself (the forms endpoint
174
+ // accepts form-encoded bodies, which a failed .json() would consume).
175
+ let body = null;
176
+ if (!route.rawBody && (request.method === "POST" || request.method === "PUT" || request.method === "PATCH")) {
177
+ try {
178
+ body = await request.json();
179
+ } catch {
180
+ body = null;
181
+ }
182
+ }
183
+
184
+ const params = {};
185
+ route.paramNames.forEach((name, i) => {
186
+ params[name] = decodeParam(m[i + 1]);
187
+ });
188
+
189
+ const ctx = {
190
+ params,
191
+ query: queryOf(url),
192
+ body,
193
+ user,
194
+ request,
195
+ runtime,
196
+ adminUiVersion,
197
+ header: (name) => request.headers.get(name),
198
+ env: (name) => (env && name in env ? env[name] : globalThis.process?.env?.[name]),
199
+ adapters: adapterBag,
200
+ config,
201
+ };
202
+
203
+ try {
204
+ return toResponse(await route.handler(ctx), cors);
205
+ } catch (err) {
206
+ return toResponse({ status: 500, json: { error: err.message } }, cors);
207
+ }
208
+ };
209
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Node ↔ Fetch transport bridge.
3
+ *
4
+ * Translates Node's IncomingMessage/ServerResponse to the Web Fetch
5
+ * Request/Response the CMS handler speaks, and back. This layer makes no
6
+ * decisions — no auth, no roles, no routing. It only moves bytes, which is
7
+ * what a transport adapter should do.
8
+ */
9
+
10
+ import { Readable } from "node:stream";
11
+
12
+ const DEFAULT_BODY_LIMIT = 10 * 1024 * 1024; // 10 MB, matching the old express.json limit
13
+
14
+ /**
15
+ * Buffer a request body. Bodies here are JSON documents (pages, templates,
16
+ * base64 media uploads), so buffering keeps the bridge simple and avoids
17
+ * half-duplex streaming caveats.
18
+ */
19
+ async function readBody(req, limit) {
20
+ const chunks = [];
21
+ let size = 0;
22
+ for await (const chunk of req) {
23
+ size += chunk.length;
24
+ if (size > limit) {
25
+ const err = new Error("Request body too large");
26
+ err.status = 413;
27
+ throw err;
28
+ }
29
+ chunks.push(chunk);
30
+ }
31
+ return Buffer.concat(chunks);
32
+ }
33
+
34
+ /** IncomingMessage → Request. */
35
+ export async function nodeToRequest(req, { bodyLimit = DEFAULT_BODY_LIMIT } = {}) {
36
+ const proto = req.headers["x-forwarded-proto"] || "http";
37
+ const host = req.headers.host || "localhost";
38
+ const url = new URL(req.url || "/", `${proto}://${host}`);
39
+
40
+ const headers = new Headers();
41
+ for (const [key, value] of Object.entries(req.headers)) {
42
+ if (value === undefined) continue;
43
+ if (Array.isArray(value)) value.forEach((v) => headers.append(key, v));
44
+ else headers.set(key, value);
45
+ }
46
+
47
+ const method = req.method || "GET";
48
+ let body;
49
+ if (method !== "GET" && method !== "HEAD") {
50
+ // A connect stack upstream may have parsed the body already.
51
+ if (req.body !== undefined) {
52
+ body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
53
+ } else {
54
+ const buffered = await readBody(req, bodyLimit);
55
+ body = buffered.length ? buffered : undefined;
56
+ }
57
+ }
58
+
59
+ return new Request(url, { method, headers, body });
60
+ }
61
+
62
+ /** Response → ServerResponse. */
63
+ export async function writeNodeResponse(res, response) {
64
+ const headers = {};
65
+ for (const [key, value] of response.headers) headers[key] = value;
66
+ res.writeHead(response.status, headers);
67
+
68
+ if (!response.body) {
69
+ res.end();
70
+ return;
71
+ }
72
+ Readable.fromWeb(response.body).pipe(res);
73
+ }
74
+
75
+ /**
76
+ * Wrap a Fetch-style handler as connect/Vite-compatible middleware.
77
+ * When the handler returns null the request is passed to `next()`, so the
78
+ * CMS can be mounted alongside a site's own middleware stack.
79
+ *
80
+ * @param {(request: Request) => Promise<Response|null>} handle
81
+ */
82
+ export function toNodeMiddleware(handle) {
83
+ return async function cmsMiddleware(req, res, next) {
84
+ try {
85
+ const request = await nodeToRequest(req);
86
+ const response = await handle(request);
87
+ if (!response) {
88
+ if (next) return next();
89
+ res.writeHead(404, { "Content-Type": "text/plain" });
90
+ return res.end("Not found");
91
+ }
92
+ await writeNodeResponse(res, response);
93
+ } catch (err) {
94
+ const status = err.status || 500;
95
+ if (!res.headersSent) res.writeHead(status, { "Content-Type": "application/json" });
96
+ res.end(JSON.stringify({ error: err.message }));
97
+ }
98
+ };
99
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Minimal static file serving for the Node runtime.
3
+ *
4
+ * Replaces express.static / res.sendFile so the server has no web-framework
5
+ * dependency. Like the Node bridge, this makes no policy decisions.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { Readable } from "node:stream";
11
+
12
+ const TYPES = {
13
+ ".html": "text/html; charset=utf-8",
14
+ ".js": "text/javascript; charset=utf-8",
15
+ ".mjs": "text/javascript; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8",
17
+ ".json": "application/json; charset=utf-8",
18
+ ".map": "application/json; charset=utf-8",
19
+ ".svg": "image/svg+xml",
20
+ ".png": "image/png",
21
+ ".jpg": "image/jpeg",
22
+ ".jpeg": "image/jpeg",
23
+ ".gif": "image/gif",
24
+ ".webp": "image/webp",
25
+ ".avif": "image/avif",
26
+ ".ico": "image/x-icon",
27
+ ".woff": "font/woff",
28
+ ".woff2": "font/woff2",
29
+ ".ttf": "font/ttf",
30
+ ".txt": "text/plain; charset=utf-8",
31
+ ".xml": "application/xml; charset=utf-8",
32
+ ".pdf": "application/pdf",
33
+ };
34
+
35
+ export function contentTypeFor(filePath) {
36
+ return TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
37
+ }
38
+
39
+ /**
40
+ * Resolve a URL sub-path inside `root`, refusing anything that escapes it.
41
+ * The trailing-separator comparison matters: a bare startsWith(root) would
42
+ * also accept a sibling directory such as "<root>-other".
43
+ */
44
+ export function resolveWithin(root, subPath) {
45
+ const rel = decodeURIComponent(String(subPath)).replace(/^\/+/, "");
46
+ const full = path.resolve(root, rel);
47
+ if (full !== root && !full.startsWith(root + path.sep)) return null;
48
+ return full;
49
+ }
50
+
51
+ /** Send one file as a Response, or null when it is missing/not a file. */
52
+ export function fileResponse(filePath, { status = 200 } = {}) {
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(filePath);
56
+ } catch {
57
+ return null;
58
+ }
59
+ if (!stat.isFile()) return null;
60
+
61
+ return new Response(Readable.toWeb(fs.createReadStream(filePath)), {
62
+ status,
63
+ headers: {
64
+ "Content-Type": contentTypeFor(filePath),
65
+ "Content-Length": String(stat.size),
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Serve `root` under the URL prefix `mount`.
72
+ *
73
+ * @param {Object} opts
74
+ * @param {string} opts.root Absolute directory to serve from.
75
+ * @param {string} opts.mount URL prefix, e.g. "/admin" or "/src/assets".
76
+ * @param {boolean} [opts.spaFallback] Serve index.html for unmatched paths.
77
+ * @returns {(request: Request) => Response|null}
78
+ */
79
+ export function createStaticHandler({ root, mount, spaFallback = false }) {
80
+ const prefix = mount.replace(/\/+$/, "");
81
+
82
+ return function serveStatic(request) {
83
+ if (request.method !== "GET" && request.method !== "HEAD") return null;
84
+
85
+ const url = new URL(request.url);
86
+ const { pathname } = url;
87
+ if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return null;
88
+
89
+ // `/admin` → `/admin/`. The SPA's index.html references its bundle
90
+ // relatively (`./assets/…`), and a browser resolves that against `/admin`
91
+ // as `/assets/…` — which 404s, leaving a blank page with no error to go on.
92
+ // Serving index.html at the bare mount is what makes it look like the
93
+ // admin UI is broken; the slash is what makes the relative URLs resolve.
94
+ if (prefix && pathname === prefix) {
95
+ return new Response(null, {
96
+ status: 301,
97
+ headers: { Location: `${prefix}/${url.search}` },
98
+ });
99
+ }
100
+
101
+ const sub = pathname.slice(prefix.length) || "/";
102
+ const target = resolveWithin(root, sub === "/" ? "index.html" : sub);
103
+ if (!target) return null;
104
+
105
+ const direct = fileResponse(target);
106
+ if (direct) return direct;
107
+
108
+ // Directory → its index.html
109
+ const asIndex = fileResponse(path.join(target, "index.html"));
110
+ if (asIndex) return asIndex;
111
+
112
+ if (spaFallback) return fileResponse(path.join(root, "index.html"));
113
+ return null;
114
+ };
115
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Derive a safe browser-facing config from the full cms.config object.
3
+ *
4
+ * Never exposes: auth credentials env var names, build tokens, git config,
5
+ * GitHub OAuth secrets, content adapter internals.
6
+ *
7
+ * Used automatically by createCmsServer when the consumer does not supply
8
+ * their own publicConfig function.
9
+ *
10
+ * @param {Object} config Full cms.config object
11
+ * @param {Object} [overrides={}] Extra fields merged in at the end (shallow)
12
+ * @returns {Object} Safe subset for the browser
13
+ */
14
+ export function defaultPublicConfig(config, overrides = {}) {
15
+ const cfg = {
16
+ mountPath: config.mountPath,
17
+ locales: config.locales,
18
+ defaultLocale: config.defaultLocale,
19
+ previewUrlPattern: config.previewUrlPattern,
20
+ media: config.media,
21
+ collections: config.collections,
22
+ blocks: config.blocks,
23
+ content: {
24
+ provider: config.content?.provider || "fs",
25
+ // Whether saving and publishing are separate steps. False only on the
26
+ // github backend without a draft branch — the admin hides Publish there,
27
+ // because saving already published.
28
+ deferredPublish:
29
+ (config.content?.provider || "fs") !== "github" || !!config.content?.draftBranch,
30
+ list: {
31
+ strategy: config.content?.list?.strategy || "index",
32
+ rebuild: config.content?.list?.rebuild || "build",
33
+ indexFile: config.content?.list?.indexFile || "_index.json",
34
+ },
35
+ },
36
+ auth: { provider: config.auth?.provider || "basic" },
37
+ };
38
+ return Object.assign(cfg, overrides);
39
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,22 @@
1
+ export {
2
+ createCmsServer,
3
+ resolveAdminUi,
4
+ resolveAdminUiOptions,
5
+ startCmsServer,
6
+ startScheduler,
7
+ } from "./server.mjs";
8
+ export { createRequestHandler } from "./core/handler.mjs";
9
+ export { toNodeMiddleware, nodeToRequest, writeNodeResponse } from "./core/node-adapter.mjs";
10
+ export { defaultPublicConfig } from "./default-public-config.mjs";
11
+ export {
12
+ createFsJsonContent,
13
+ createFsTemplates,
14
+ createGitHubContent,
15
+ createGitHubTemplates,
16
+ createLocalAssetsMedia,
17
+ createCdnProxyMedia,
18
+ createBasicAuth,
19
+ createGitHubOAuth,
20
+ createNetlifyBuild,
21
+ createMediaUrl,
22
+ } from "./adapters/index.mjs";