@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 +2 -2
- package/src/core/config-schema.mjs +49 -2
- package/src/core/forms.mjs +45 -0
- package/src/routes.mjs +35 -3
- package/src/server.mjs +43 -9
- package/src/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stelstone/server",
|
|
3
|
-
"version": "0.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/core/forms.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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:
|
|
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