@stelstone/server 0.26.2 → 0.27.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stelstone/server",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
4
4
  "description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "type": "module",
19
19
  "bin": {
20
- "stelstone": "./bin/stelstone.mjs"
20
+ "stelstone": "bin/stelstone.mjs"
21
21
  },
22
22
  "scripts": {
23
23
  "test": "node --test 'test/**/*.test.mjs'"
@@ -247,7 +247,9 @@ function checkForms(config, report, { getSecret }) {
247
247
  report.error("forms", "must be an object of formName → definition");
248
248
  return;
249
249
  }
250
- if (Object.keys(forms).length && !mail) {
250
+ // A form with a forward has somewhere to put a submission even without mail.
251
+ const needsMail = Object.values(forms).some((def) => !isPlainObject(def) || !def.forward);
252
+ if (needsMail && !mail) {
251
253
  report.warn("forms", "defined without config.mail — submissions will answer 503 until mail is configured");
252
254
  }
253
255
  for (const [name, def] of Object.entries(forms)) {
@@ -256,8 +258,12 @@ function checkForms(config, report, { getSecret }) {
256
258
  report.error(at, "must be an object");
257
259
  continue;
258
260
  }
259
- if (!def.to || typeof def.to !== "string" || !def.to.includes("@")) {
261
+ // `to` is what the e-mail copy needs. A form that only forwards has
262
+ // somewhere to put a submission without one.
263
+ if (def.to === undefined && !def.forward) {
260
264
  report.error(`${at}.to`, "is required and must be an email address");
265
+ } else if (def.to !== undefined && (typeof def.to !== "string" || !def.to.includes("@"))) {
266
+ report.error(`${at}.to`, "must be an email address");
261
267
  }
262
268
  if (def.subject !== undefined && typeof def.subject !== "string" && typeof def.subject !== "function") {
263
269
  report.error(`${at}.subject`, "must be a string or a (fields) => string function");
@@ -271,6 +277,47 @@ function checkForms(config, report, { getSecret }) {
271
277
  report.warn(`${at}.turnstile`, `${secretEnv} is not set — verification will answer 503`);
272
278
  }
273
279
  }
280
+ checkForward(def.forward, `${at}.forward`, report, getSecret);
281
+ }
282
+ }
283
+
284
+ /** `forward`: post the submission on to another service, e.g. a CRM. */
285
+ function checkForward(forward, at, report, getSecret) {
286
+ if (forward === undefined) return;
287
+ if (!isPlainObject(forward)) {
288
+ report.error(at, "must be an object like { url, keyEnv?, body? }");
289
+ return;
290
+ }
291
+
292
+ let url;
293
+ try {
294
+ url = new URL(forward.url);
295
+ } catch {
296
+ report.error(`${at}.url`, "is required and must be an absolute http(s) URL");
297
+ }
298
+ if (forward.urlEnv !== undefined && typeof forward.urlEnv !== "string") {
299
+ report.error(`${at}.urlEnv`, "must be the NAME of an environment variable overriding url");
300
+ }
301
+ if (url && url.protocol !== "https:" && url.hostname !== "localhost") {
302
+ report.error(`${at}.url`, "must use https outside localhost — the payload carries personal data");
303
+ }
304
+
305
+ if (forward.body !== undefined && typeof forward.body !== "function") {
306
+ report.error(`${at}.body`, "must be a (fields) => object function");
307
+ }
308
+ if (forward.headers !== undefined && !isPlainObject(forward.headers)) {
309
+ report.error(`${at}.headers`, "must be an object of header → value");
310
+ }
311
+ if (forward.keyHeader !== undefined && typeof forward.keyHeader !== "string") {
312
+ report.error(`${at}.keyHeader`, "must be a header name like \"x-api-key\"");
313
+ }
314
+
315
+ if (forward.keyEnv === undefined) {
316
+ report.warn(`${at}.keyEnv`, "is not set — the request will carry no credential");
317
+ } else if (typeof forward.keyEnv !== "string") {
318
+ report.error(`${at}.keyEnv`, "must be the NAME of the environment variable holding the key");
319
+ } else if (!getSecret(forward.keyEnv)) {
320
+ report.warn(`${at}.keyEnv`, `${forward.keyEnv} is not set — forwarding will fail`);
274
321
  }
275
322
  }
276
323
 
@@ -64,6 +64,51 @@ export function formatMessage(fields, { skip = [] } = {}) {
64
64
  .join("\n");
65
65
  }
66
66
 
67
+ /** The fields a sink should see: no honeypot, no spent Turnstile token. */
68
+ export function submissionFields(fields, honeypot) {
69
+ const hidden = new Set([honeypot, "form-name", "cf-turnstile-response"]);
70
+ return Object.fromEntries(Object.entries(fields).filter(([key]) => !hidden.has(key)));
71
+ }
72
+
73
+ /**
74
+ * Posts a submission on to another HTTP service — a CRM, an automation hook,
75
+ * whatever the site points it at. The secret travels by env name so it lives
76
+ * in the deployment rather than in cms.config, and the shape is the site's to
77
+ * decide: `body(fields)` maps the form onto whatever the far end expects, and
78
+ * without it the cleaned fields go as they are.
79
+ *
80
+ * Throws on anything but a 2xx; the caller decides what a failure means.
81
+ */
82
+ export async function postForward(forward, fields, env) {
83
+ // `urlEnv` wins when the deployment sets it, so one config can point a
84
+ // preview worker at a staging service without editing the file. A Worker
85
+ // reads bindings rather than process.env, which is why this is a name to
86
+ // look up rather than something the config computes at import time.
87
+ const url = (forward.urlEnv && env(forward.urlEnv)) || forward.url;
88
+ const headers = { "content-type": "application/json", ...(forward.headers ?? {}) };
89
+ if (forward.keyEnv) {
90
+ const key = env(forward.keyEnv);
91
+ if (!key) throw new Error(`${forward.keyEnv} is not set`);
92
+ headers[forward.keyHeader ?? "x-api-key"] = key;
93
+ }
94
+
95
+ const body = typeof forward.body === "function" ? forward.body(fields) : fields;
96
+ const res = await fetch(url, {
97
+ method: "POST",
98
+ headers,
99
+ body: JSON.stringify(body),
100
+ });
101
+ if (!res.ok) {
102
+ // Keep the far end's own words — a 400 from a CRM usually names the field
103
+ // it rejected, and that is what makes the failure fixable.
104
+ const detail = (await res.text().catch(() => "")).slice(0, 300).trim();
105
+ throw new Error(
106
+ `Forward to ${new URL(url).host} failed with HTTP ${res.status}` +
107
+ (detail ? `: ${detail}` : ""),
108
+ );
109
+ }
110
+ }
111
+
67
112
  /**
68
113
  * Fixed-window in-memory rate limiter for the Node runtime. Per-process by
69
114
  * design — the Node server is a single process, and forms are a trickle.
package/src/routes.mjs CHANGED
@@ -26,7 +26,14 @@
26
26
 
27
27
  import { queryPages } from "./adapters/_shared.mjs";
28
28
  import { SERVER_VERSION } from "./version.mjs";
29
- import { readFormFields, fieldsError, formatMessage, clientIp } from "./core/forms.mjs";
29
+ import {
30
+ readFormFields,
31
+ fieldsError,
32
+ formatMessage,
33
+ clientIp,
34
+ submissionFields,
35
+ postForward,
36
+ } from "./core/forms.mjs";
30
37
 
31
38
  function ok(json) {
32
39
  return { json };
@@ -516,7 +523,26 @@ export const apiRoutes = [
516
523
  return { status: 429, json: { error: "Too many submissions — please wait a minute" } };
517
524
  }
518
525
 
519
- if (!adapters.mail?.configured) {
526
+ // Two sinks, and a submission survives on either one. The forward runs
527
+ // first so that when it fails the e-mail can carry the reason to whoever
528
+ // reads it — otherwise a CRM outage is invisible until someone notices
529
+ // the leads stopped arriving.
530
+ let forwardError = null;
531
+ if (def.forward) {
532
+ try {
533
+ await postForward(def.forward, submissionFields(fields, honeypot), env);
534
+ } catch (err) {
535
+ forwardError = err.message;
536
+ }
537
+ }
538
+ const forwarded = !!def.forward && !forwardError;
539
+
540
+ // No `to` means the form was set up to forward only — there is no
541
+ // e-mail copy to send, and nothing missing.
542
+ const mailReady = !!def.to && !!adapters.mail?.configured;
543
+ if (!mailReady) {
544
+ if (forwarded) return success();
545
+ if (forwardError) return { status: 502, json: { error: forwardError } };
520
546
  return {
521
547
  status: 503,
522
548
  json: { error: "Mail delivery is not configured — set config.mail and its API key" },
@@ -528,15 +554,21 @@ export const apiRoutes = [
528
554
  ? def.subject(fields)
529
555
  : def.subject || `Yeni form gönderimi: ${params.name}`;
530
556
  const replyTo = def.replyTo ? fields[def.replyTo] : undefined;
557
+ const text = forwardError
558
+ ? `${formatMessage(fields, { skip: [honeypot] })}\n\n---\nBu gönderim CRM'e iletilemedi: ${forwardError}`
559
+ : formatMessage(fields, { skip: [honeypot] });
531
560
 
532
561
  try {
533
562
  await adapters.mail.send({
534
563
  to: def.to,
535
564
  subject,
536
- text: formatMessage(fields, { skip: [honeypot] }),
565
+ text,
537
566
  ...(replyTo ? { replyTo } : {}),
538
567
  });
539
568
  } catch (err) {
569
+ // The forward already has the submission, so the visitor has nothing
570
+ // to retry — only the copy for humans went missing.
571
+ if (forwarded) return success();
540
572
  return { status: 502, json: { error: err.message } };
541
573
  }
542
574
  return success();
package/src/server.mjs CHANGED
@@ -155,20 +155,44 @@ export function startScheduler(content, intervalMs = 60_000) {
155
155
  return () => clearInterval(timer);
156
156
  }
157
157
 
158
+ const PREVIEW_THEME_PATH = "/admin/preview-theme.css";
159
+
160
+ /**
161
+ * The per-site canvas stylesheet.
162
+ *
163
+ * Always answered, even when the site configured none: the admin SPA links it
164
+ * unconditionally, and every mount ends in an SPA fallback that would hand the
165
+ * browser index.html labelled as CSS.
166
+ */
167
+ function previewThemeResponse(previewThemeCss) {
168
+ if (previewThemeCss && fs.existsSync(previewThemeCss)) {
169
+ return fileResponse(path.resolve(previewThemeCss));
170
+ }
171
+ return new Response("/* no preview-theme.css configured */", {
172
+ headers: { "Content-Type": "text/css" },
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Connect middleware that answers the preview stylesheet and passes everything
178
+ * else along. Used by the Vite mount, whose own SPA fallback would otherwise
179
+ * swallow the path.
180
+ */
181
+ export function createPreviewThemeMiddleware(previewThemeCss) {
182
+ return toNodeMiddleware(async (request) =>
183
+ new URL(request.url).pathname === PREVIEW_THEME_PATH
184
+ ? previewThemeResponse(previewThemeCss)
185
+ : null,
186
+ );
187
+ }
188
+
158
189
  /** Serve the prebuilt admin SPA, plus the optional per-site preview stylesheet. */
159
190
  function createAdminUiHandler({ dir, previewThemeCss }) {
160
191
  const files = createStaticHandler({ root: dir, mount: "/admin", spaFallback: true });
161
192
  return function serveAdminUi(request) {
162
193
  const { pathname } = new URL(request.url);
163
194
  // Answer before the static handler so the <link> never 404s.
164
- if (pathname === "/admin/preview-theme.css") {
165
- if (previewThemeCss && fs.existsSync(previewThemeCss)) {
166
- return fileResponse(path.resolve(previewThemeCss));
167
- }
168
- return new Response("/* no preview-theme.css configured */", {
169
- headers: { "Content-Type": "text/css" },
170
- });
171
- }
195
+ if (pathname === PREVIEW_THEME_PATH) return previewThemeResponse(previewThemeCss);
172
196
  return files(request);
173
197
  };
174
198
  }
@@ -258,8 +282,18 @@ export async function resolveAdminUi(adminUi) {
258
282
  appType: "spa",
259
283
  });
260
284
  console.log("Admin UI: Vite dev middleware (HMR enabled)");
285
+
286
+ // Vite owns /admin here, and its SPA fallback answers every unmatched path
287
+ // with index.html — so the preview stylesheet arrived as HTML and the
288
+ // browser dropped it, leaving the canvas unstyled whenever the admin UI
289
+ // ran from source. Only the static mount used to answer this path; now
290
+ // both do, and `previewThemeCss` means the same thing in dev and in prod.
291
+ const viteMiddleware = mountPrefix("/admin", vite.middlewares);
292
+ const previewTheme = createPreviewThemeMiddleware(resolved.previewThemeCss);
293
+
261
294
  return {
262
- nodeMiddleware: mountPrefix("/admin", vite.middlewares),
295
+ nodeMiddleware: (req, res, next) =>
296
+ previewTheme(req, res, () => viteMiddleware(req, res, next)),
263
297
  previewThemeCss: resolved.previewThemeCss,
264
298
  };
265
299
  }
package/src/version.mjs CHANGED
@@ -5,4 +5,4 @@
5
5
  * require() and no import.meta.url, so reading the manifest at runtime yields
6
6
  * "unknown" there.
7
7
  */
8
- export const SERVER_VERSION = "0.26.2";
8
+ export const SERVER_VERSION = "0.27.0";