@stelstone/server 0.26.3 → 0.28.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.3",
3
+ "version": "0.28.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'"
@@ -0,0 +1,104 @@
1
+ /**
2
+ * GitHub Actions workflow runs as the deploy status source.
3
+ *
4
+ * For sites whose CI does the deploying — a Worker, a container, an rsync to
5
+ * a VPS — the workflow run IS the deploy, so its state is the honest thing to
6
+ * show. The adapter speaks the same vocabulary as the Netlify one so the
7
+ * admin's pill needs no per-provider knowledge.
8
+ *
9
+ * @param {Object} opts
10
+ * @param {string|undefined} opts.token repo-scoped token; `actions:read`
11
+ * @param {string|undefined} opts.owner
12
+ * @param {string|undefined} opts.repo
13
+ * @param {string|undefined} opts.workflow file name ("deploy.yml") or id;
14
+ * omit to watch every workflow
15
+ * @param {string|undefined} opts.defaultBranch
16
+ * @returns {import('./types.mjs').BuildAdapter}
17
+ */
18
+ import { createGitHubApi } from "./github-api.mjs";
19
+
20
+ /**
21
+ * A finished run's conclusion, in the admin's state vocabulary.
22
+ *
23
+ * Anything that did not succeed but also did not fail outright — skipped by a
24
+ * path filter, superseded, stopped by hand — reads as "cancelled": the deploy
25
+ * did not happen, and that is not the same as a broken build.
26
+ */
27
+ const CONCLUSION_STATE = {
28
+ success: "ready",
29
+ failure: "error",
30
+ timed_out: "error",
31
+ startup_failure: "error",
32
+ action_required: "error",
33
+ cancelled: "cancelled",
34
+ skipped: "cancelled",
35
+ stale: "cancelled",
36
+ neutral: "cancelled",
37
+ };
38
+
39
+ /** Queued and waiting-for-approval runs have not started; both are "queued". */
40
+ const PENDING_STATUSES = new Set(["queued", "pending", "waiting", "requested"]);
41
+
42
+ function runState(run) {
43
+ if (run.status === "completed") return CONCLUSION_STATE[run.conclusion] || "error";
44
+ return PENDING_STATUSES.has(run.status) ? "enqueued" : "building";
45
+ }
46
+
47
+ export function createGitHubActionsBuild({ token, owner, repo, workflow, defaultBranch }) {
48
+ const configured = Boolean(token && owner && repo);
49
+ const api = configured ? createGitHubApi({ token, owner, repo }) : null;
50
+
51
+ return {
52
+ configured,
53
+
54
+ async getDeployStatus({ branch, sha } = {}) {
55
+ if (!configured) return { configured: false };
56
+
57
+ const query = new URLSearchParams({ per_page: "20" });
58
+ const targetBranch = branch || defaultBranch;
59
+ if (targetBranch) query.set("branch", targetBranch);
60
+ const path = workflow
61
+ ? `/actions/workflows/${encodeURIComponent(workflow)}/runs?${query}`
62
+ : `/actions/runs?${query}`;
63
+
64
+ const body = await api.apiGet(path);
65
+ // apiGet answers null on 404 — for a run listing that means the repo,
66
+ // the workflow name or the token is wrong, not "no runs yet". Say so
67
+ // instead of returning an empty list the caller would poll forever.
68
+ if (!body) {
69
+ const err = new Error(`GitHub API 404 GET ${path}`);
70
+ err.upstreamStatus = 404;
71
+ throw err;
72
+ }
73
+
74
+ const runs = body.workflow_runs || [];
75
+ let run = null;
76
+ if (sha) {
77
+ run = runs.find((r) => r.head_sha && r.head_sha.startsWith(sha));
78
+ // SHA not there yet — the push has not produced a run, keep waiting.
79
+ if (!run) return { configured: true, deploy: null };
80
+ }
81
+ if (!run) run = runs[0] || null;
82
+ if (!run) return { configured: true, deploy: null };
83
+
84
+ return {
85
+ configured: true,
86
+ deploy: {
87
+ id: run.id,
88
+ state: runState(run),
89
+ branch: run.head_branch,
90
+ commitRef: run.head_sha,
91
+ // Actions knows what it ran, not where the result is published, so
92
+ // the run page is the only link worth offering.
93
+ deployUrl: null,
94
+ adminUrl: run.html_url || null,
95
+ createdAt: run.created_at,
96
+ updatedAt: run.updated_at,
97
+ // A run carries no failure text; the run page has the logs.
98
+ errorMessage: null,
99
+ title: run.display_title || run.name || null,
100
+ },
101
+ };
102
+ },
103
+ };
104
+ }
@@ -8,5 +8,6 @@ export { createBasicAuth } from "./basic-auth.mjs";
8
8
  export { createGitHubOAuth } from "./github-oauth.mjs";
9
9
  export { createCloudflareAccess } from "./cloudflare-access.mjs";
10
10
  export { createNetlifyBuild } from "./build-netlify.mjs";
11
+ export { createGitHubActionsBuild } from "./build-github-actions.mjs";
11
12
  export { createMediaUrl } from "./media-url.mjs";
12
13
  export { createResendMail } from "./resend-mail.mjs";
@@ -16,20 +16,29 @@
16
16
  /** @typedef {(name: string) => string|undefined} SecretLookup */
17
17
 
18
18
  /**
19
- * Options for `createGitHubContent`.
20
- * @param {Object} config
19
+ * GITHUB_REPO ("owner/repo") overrides the config — set where the secrets
20
+ * already live, so a deploy-button user never edits a file. Lenient about
21
+ * pasted URLs: the last two path segments are the coordinates.
22
+ *
21
23
  * @param {SecretLookup} secrets
24
+ * @returns {{owner?: string, repo?: string}}
22
25
  */
23
- export function githubContentOptions(config, secrets) {
24
- // GITHUB_REPO ("owner/repo") overrides the config — set where the secrets
25
- // already live, so a deploy-button user never edits a file. Lenient about
26
- // pasted URLs: the last two path segments are the coordinates.
27
- const repoEnv = String(secrets("GITHUB_REPO") ?? "")
26
+ function repoFromEnv(secrets) {
27
+ const parts = String(secrets("GITHUB_REPO") ?? "")
28
28
  .replace(/\.git$/, "")
29
29
  .split(/[/:]/)
30
30
  .filter(Boolean)
31
31
  .slice(-2);
32
- const [envOwner, envRepo] = repoEnv.length === 2 ? repoEnv : [];
32
+ return parts.length === 2 ? { owner: parts[0], repo: parts[1] } : {};
33
+ }
34
+
35
+ /**
36
+ * Options for `createGitHubContent`.
37
+ * @param {Object} config
38
+ * @param {SecretLookup} secrets
39
+ */
40
+ export function githubContentOptions(config, secrets) {
41
+ const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
33
42
  return {
34
43
  token: secrets(config.content.githubTokenEnv || "GITHUB_TOKEN"),
35
44
  owner: envOwner || config.content.owner,
@@ -135,11 +144,54 @@ export function netlifyBuildOptions(config, secrets) {
135
144
  return {
136
145
  token: secrets(config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"),
137
146
  siteId: secrets(config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"),
138
- defaultBranch:
139
- secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch,
147
+ defaultBranch: buildDefaultBranch(config, secrets),
140
148
  };
141
149
  }
142
150
 
151
+ /**
152
+ * The branch whose deploy the admin's status pill follows. `STAGING_BRANCH`
153
+ * wins for the same reason it does for content: it is what redirects a whole
154
+ * deployment onto another branch.
155
+ */
156
+ function buildDefaultBranch(config, secrets) {
157
+ return secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch;
158
+ }
159
+
160
+ /**
161
+ * Which build adapter to construct, and with what.
162
+ *
163
+ * Mirrors `authOptions`: the caller stays free of the naming rules and only
164
+ * has to map a provider name onto a factory. Netlify remains the default so
165
+ * existing configs keep working untouched.
166
+ *
167
+ * @param {Object} config
168
+ * @param {SecretLookup} secrets
169
+ * @returns {{provider: string, options: object}}
170
+ */
171
+ export function buildOptions(config, secrets) {
172
+ const build = config.build ?? {};
173
+ const provider = build.provider ?? "netlify";
174
+
175
+ if (provider === "github-actions") {
176
+ // Defaults to the repo the content already comes from — for a site whose
177
+ // CI deploys it, that is nearly always the same repo, and the token that
178
+ // reads content can read its runs.
179
+ const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
180
+ return {
181
+ provider,
182
+ options: {
183
+ token: secrets(build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN"),
184
+ owner: envOwner || build.owner || config.content?.owner,
185
+ repo: envRepo || build.repo || config.content?.repo,
186
+ workflow: build.workflow,
187
+ defaultBranch: buildDefaultBranch(config, secrets),
188
+ },
189
+ };
190
+ }
191
+
192
+ return { provider: "netlify", options: netlifyBuildOptions(config, secrets) };
193
+ }
194
+
143
195
  /**
144
196
  * Options for `createCdnProxyMedia`.
145
197
  * @param {Object} config
@@ -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,94 @@ 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`);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * `build` decides where the admin's deploy pill gets its status. It is
326
+ * optional — a site with no CI simply shows no pill — but a misspelled
327
+ * provider silently falls back to Netlify, which for a site that does not use
328
+ * Netlify means a pill that never resolves. Name it.
329
+ */
330
+ function checkBuild(config, report, { getSecret }) {
331
+ const build = config.build;
332
+ if (build === undefined) return;
333
+ if (typeof build !== "object" || build === null) {
334
+ report.error("build", "must be an object");
335
+ return;
336
+ }
337
+
338
+ const provider = build.provider ?? "netlify";
339
+ if (!["netlify", "github-actions"].includes(provider)) {
340
+ report.error("build.provider", `unknown provider "${provider}" — expected netlify or github-actions`);
341
+ return;
342
+ }
343
+
344
+ if (provider === "github-actions") {
345
+ const owner = build.owner || config.content?.owner;
346
+ const repo = build.repo || config.content?.repo;
347
+ // GITHUB_REPO can supply both at runtime, so this is a warning: the
348
+ // coordinates may well arrive from the environment.
349
+ if (!owner || !repo) {
350
+ report.warn(
351
+ "build",
352
+ "owner/repo not in the config — deploy status needs them, from build.owner/build.repo, content, or GITHUB_REPO",
353
+ );
354
+ }
355
+ const tokenEnv = build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN";
356
+ if (!getSecret(tokenEnv)) {
357
+ report.warn("build.githubTokenEnv", `${tokenEnv} is not set — deploy status is unavailable`);
358
+ }
359
+ return;
360
+ }
361
+
362
+ for (const [key, fallback] of [
363
+ ["netlifyTokenEnv", "NETLIFY_AUTH_TOKEN"],
364
+ ["netlifySiteIdEnv", "NETLIFY_SITE_ID"],
365
+ ]) {
366
+ const name = build[key] || fallback;
367
+ if (!getSecret(name)) report.warn(`build.${key}`, `${name} is not set — deploy status is unavailable`);
274
368
  }
275
369
  }
276
370
 
@@ -325,6 +419,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
325
419
  checkAuth(config, report, { getSecret: lookup });
326
420
  checkCollections(config, report);
327
421
  checkForms(config, report, { getSecret: lookup });
422
+ checkBuild(config, report, { getSecret: lookup });
328
423
  checkMisc(config, report);
329
424
 
330
425
  return { errors: report.errors, warnings: report.warnings };
@@ -398,7 +493,10 @@ export function describeConfig(config, { adapters, getSecret, runtime = "node" }
398
493
  lines.push(`media ${config.media.cdnBase}${config.media.tenantId ? ` (tenant: ${config.media.tenantId})` : ""}`);
399
494
  }
400
495
  if (adapters?.build) {
401
- lines.push(`build netlify ${adapters.build.configured ? "configured" : "not configured"}`);
496
+ const buildProvider = config.build?.provider ?? "netlify";
497
+ lines.push(
498
+ `build ${buildProvider} — ${adapters.build.configured ? "configured" : "not configured"}`,
499
+ );
402
500
  }
403
501
 
404
502
  const collections = Object.keys(config.collections || {});
@@ -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
@@ -12,7 +12,7 @@ import {
12
12
  fsContentOptions,
13
13
  githubTemplatesOptions,
14
14
  authOptions,
15
- netlifyBuildOptions,
15
+ buildOptions,
16
16
  cdnMediaOptions,
17
17
  mailOptions,
18
18
  } from "./core/adapter-options.mjs";
@@ -25,6 +25,7 @@ import {
25
25
  createGitHubOAuth,
26
26
  createCloudflareAccess,
27
27
  createNetlifyBuild,
28
+ createGitHubActionsBuild,
28
29
  createFsTemplates,
29
30
  createGitHubTemplates,
30
31
  createResendMail,
@@ -79,7 +80,11 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
79
80
  // No media config → no CDN adapter; the media routes answer 404 instead
80
81
  // of every /api request dying while the adapter bag is built.
81
82
  const cdnMedia = config.media ? createCdnProxyMedia(cdnMediaOptions(config, auth)) : undefined;
82
- const build = createNetlifyBuild(netlifyBuildOptions(config, secrets));
83
+ const { provider: buildProvider, options: buildOpts } = buildOptions(config, secrets);
84
+ const build =
85
+ buildProvider === "github-actions"
86
+ ? createGitHubActionsBuild(buildOpts)
87
+ : createNetlifyBuild(buildOpts);
83
88
 
84
89
  const templates = useGitHub
85
90
  ? createGitHubTemplates(githubTemplatesOptions(config, secrets))
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.3";
8
+ export const SERVER_VERSION = "0.28.0";