@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.
- package/README.md +275 -0
- package/bin/stelstone.mjs +181 -0
- package/package.json +53 -0
- package/src/adapters/_shared.mjs +401 -0
- package/src/adapters/basic-auth.mjs +102 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +79 -0
- package/src/adapters/cloudflare-access.mjs +144 -0
- package/src/adapters/fs-json-content.mjs +302 -0
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +100 -0
- package/src/adapters/github-content.mjs +577 -0
- package/src/adapters/github-oauth.mjs +153 -0
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +12 -0
- package/src/adapters/local-assets-media.mjs +68 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/resend-mail.mjs +41 -0
- package/src/adapters/types.mjs +104 -0
- package/src/admin-ui-path.mjs +77 -0
- package/src/core/adapter-options.mjs +167 -0
- package/src/core/config-schema.mjs +408 -0
- package/src/core/forms.mjs +99 -0
- package/src/core/handler.mjs +209 -0
- package/src/core/node-adapter.mjs +99 -0
- package/src/core/static-files.mjs +115 -0
- package/src/default-public-config.mjs +39 -0
- package/src/index.mjs +22 -0
- package/src/routes.mjs +737 -0
- package/src/server.mjs +325 -0
- package/src/version.mjs +8 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure config → adapter-options builders.
|
|
3
|
+
*
|
|
4
|
+
* The Node server and the Worker construct different adapter sets — only Node
|
|
5
|
+
* has a filesystem and a git binary — but they were deriving the *same* option
|
|
6
|
+
* values independently: which env var holds which secret, what `STAGING_BRANCH`
|
|
7
|
+
* overrides, what the fallbacks are. That is the duplication that produced the
|
|
8
|
+
* drift between the two runtimes everywhere else, so it lives here once.
|
|
9
|
+
*
|
|
10
|
+
* These functions import nothing and touch no environment: secrets arrive
|
|
11
|
+
* through the injected `secrets(name)` lookup. That keeps them testable, and it
|
|
12
|
+
* keeps Node-only adapter modules out of the Worker bundle — each runtime still
|
|
13
|
+
* imports only the factories it can actually run.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @typedef {(name: string) => string|undefined} SecretLookup */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Options for `createGitHubContent`.
|
|
20
|
+
* @param {Object} config
|
|
21
|
+
* @param {SecretLookup} secrets
|
|
22
|
+
*/
|
|
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") ?? "")
|
|
28
|
+
.replace(/\.git$/, "")
|
|
29
|
+
.split(/[/:]/)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.slice(-2);
|
|
32
|
+
const [envOwner, envRepo] = repoEnv.length === 2 ? repoEnv : [];
|
|
33
|
+
return {
|
|
34
|
+
token: secrets(config.content.githubTokenEnv || "GITHUB_TOKEN"),
|
|
35
|
+
owner: envOwner || config.content.owner,
|
|
36
|
+
repo: envRepo || config.content.repo,
|
|
37
|
+
branch: secrets("STAGING_BRANCH") || config.content.branch || "main",
|
|
38
|
+
draftBranch: config.content.draftBranch,
|
|
39
|
+
pagesDir: config.content.pagesDir,
|
|
40
|
+
commitMessage: config.content.commitMessage,
|
|
41
|
+
list: config.content.list,
|
|
42
|
+
collections: config.collections,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Options for `createFsJsonContent`. Node only.
|
|
48
|
+
* @param {Object} config
|
|
49
|
+
* @param {SecretLookup} secrets
|
|
50
|
+
* @param {string} rootDir
|
|
51
|
+
*/
|
|
52
|
+
export function fsContentOptions(config, secrets, rootDir) {
|
|
53
|
+
return {
|
|
54
|
+
rootDir,
|
|
55
|
+
pagesDir: config.content.pagesDir,
|
|
56
|
+
publishBranch: secrets("STAGING_BRANCH") || config.content.publishBranch,
|
|
57
|
+
publishPaths: config.content.publishPaths,
|
|
58
|
+
commitMessage: config.content.commitMessage,
|
|
59
|
+
collections: config.collections,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options for `createGitHubTemplates`. */
|
|
64
|
+
export function githubTemplatesOptions(config, secrets) {
|
|
65
|
+
return {
|
|
66
|
+
token: secrets(config.content.githubTokenEnv || "GITHUB_TOKEN"),
|
|
67
|
+
owner: config.content.owner,
|
|
68
|
+
repo: config.content.repo,
|
|
69
|
+
branch: secrets("STAGING_BRANCH") || config.content.branch || "main",
|
|
70
|
+
templatesDir: config.content.templatesDir,
|
|
71
|
+
commitMessage: config.content.commitMessage,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Which auth factory to use, and the options for it.
|
|
77
|
+
*
|
|
78
|
+
* Secrets — including every `users[].passEnv` — are resolved here rather than
|
|
79
|
+
* inside the adapter. An adapter that reads `process.env` itself cannot work in
|
|
80
|
+
* a Worker, which is exactly why the Worker used to re-resolve the user list by
|
|
81
|
+
* hand before handing it over.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} config
|
|
84
|
+
* @param {SecretLookup} secrets
|
|
85
|
+
* @returns {{ provider: "basic"|"github-oauth"|"cloudflare-access", options: Object }}
|
|
86
|
+
*/
|
|
87
|
+
export function authOptions(config, secrets) {
|
|
88
|
+
const auth = config.auth ?? {};
|
|
89
|
+
const provider = auth.provider ?? "basic";
|
|
90
|
+
|
|
91
|
+
if (provider === "github-oauth") {
|
|
92
|
+
return {
|
|
93
|
+
provider,
|
|
94
|
+
options: {
|
|
95
|
+
clientId: secrets(auth.githubClientIdEnv || "GITHUB_CLIENT_ID"),
|
|
96
|
+
clientSecret: secrets(auth.githubClientSecretEnv || "GITHUB_CLIENT_SECRET"),
|
|
97
|
+
allowedLogins: auth.allowedLogins || [],
|
|
98
|
+
roles: auth.roles || {},
|
|
99
|
+
defaultRole: auth.defaultRole,
|
|
100
|
+
jwtSecret: secrets(auth.jwtSecretEnv),
|
|
101
|
+
jwtTtl: auth.jwtTtl,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (provider === "cloudflare-access") {
|
|
107
|
+
return {
|
|
108
|
+
provider,
|
|
109
|
+
options: {
|
|
110
|
+
teamDomain: auth.teamDomain,
|
|
111
|
+
audience: auth.audience,
|
|
112
|
+
roles: auth.roles || {},
|
|
113
|
+
defaultRole: auth.defaultRole || "editor",
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
provider: "basic",
|
|
120
|
+
options: {
|
|
121
|
+
user: secrets(auth.userEnv) || "admin",
|
|
122
|
+
pass: secrets(auth.passEnv),
|
|
123
|
+
users: auth.users?.map((entry) => ({
|
|
124
|
+
...entry,
|
|
125
|
+
pass: entry.passEnv ? secrets(entry.passEnv) : entry.pass,
|
|
126
|
+
})),
|
|
127
|
+
jwtSecret: secrets(auth.jwtSecretEnv),
|
|
128
|
+
jwtTtl: auth.jwtTtl,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Options for `createNetlifyBuild`. */
|
|
134
|
+
export function netlifyBuildOptions(config, secrets) {
|
|
135
|
+
return {
|
|
136
|
+
token: secrets(config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"),
|
|
137
|
+
siteId: secrets(config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"),
|
|
138
|
+
defaultBranch:
|
|
139
|
+
secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Options for `createCdnProxyMedia`.
|
|
145
|
+
* @param {Object} config
|
|
146
|
+
* @param {Object} auth The constructed auth adapter (for media tokens).
|
|
147
|
+
*/
|
|
148
|
+
export function cdnMediaOptions(config, auth) {
|
|
149
|
+
return {
|
|
150
|
+
baseUrl: config.media.cdnBase,
|
|
151
|
+
getToken: () => auth.issueMediaToken(config.media.tenantId),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Options for `createResendMail` — the forms module's delivery. Missing
|
|
157
|
+
* config.mail means "forms deliver nowhere": the adapter reports
|
|
158
|
+
* `configured: false` and the route answers 503 instead of pretending.
|
|
159
|
+
* @param {Object} config
|
|
160
|
+
* @param {SecretLookup} secrets
|
|
161
|
+
*/
|
|
162
|
+
export function mailOptions(config, secrets) {
|
|
163
|
+
return {
|
|
164
|
+
apiKey: secrets(config.mail?.keyEnv || "RESEND_API_KEY"),
|
|
165
|
+
from: config.mail?.from,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cms.config validation and startup preflight.
|
|
3
|
+
*
|
|
4
|
+
* The config was previously read ad hoc, field by field, wherever a value was
|
|
5
|
+
* needed. Mistakes therefore surfaced as behaviour rather than errors: a typo
|
|
6
|
+
* in a `passEnv` name produced a user whose password resolved to `undefined`,
|
|
7
|
+
* which for months meant an account that could be logged into with an empty
|
|
8
|
+
* password. Nothing announced it.
|
|
9
|
+
*
|
|
10
|
+
* So: validate the whole shape up front, refuse to start on an error, and
|
|
11
|
+
* print what was actually resolved. A wrong config should be loud and
|
|
12
|
+
* immediate, never quietly wrong.
|
|
13
|
+
*
|
|
14
|
+
* No schema library — the CMS ships zero runtime dependencies and this runs in
|
|
15
|
+
* a Worker, where every kilobyte is in the request path.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CONTENT_PROVIDERS = ["fs", "github"];
|
|
19
|
+
const AUTH_PROVIDERS = ["basic", "github-oauth", "cloudflare-access"];
|
|
20
|
+
|
|
21
|
+
class Report {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.errors = [];
|
|
24
|
+
this.warnings = [];
|
|
25
|
+
}
|
|
26
|
+
error(path, message) {
|
|
27
|
+
this.errors.push({ path, message });
|
|
28
|
+
}
|
|
29
|
+
warn(path, message) {
|
|
30
|
+
this.warnings.push({ path, message });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isPlainObject(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkContent(config, report, { runtime }) {
|
|
39
|
+
const content = config.content;
|
|
40
|
+
if (!isPlainObject(content)) {
|
|
41
|
+
report.error("content", "is required and must be an object");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const provider = content.provider ?? "fs";
|
|
46
|
+
if (!CONTENT_PROVIDERS.includes(provider)) {
|
|
47
|
+
report.error("content.provider", `must be one of ${CONTENT_PROVIDERS.join(", ")} (got "${provider}")`);
|
|
48
|
+
}
|
|
49
|
+
if (!content.pagesDir) {
|
|
50
|
+
report.error("content.pagesDir", "is required — the directory holding page JSON files");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A Worker has no filesystem and no git binary, so it always talks to GitHub
|
|
54
|
+
// whatever the config says. Missing repo coordinates are fatal there.
|
|
55
|
+
const needsRepo = provider === "github" || runtime === "worker";
|
|
56
|
+
if (needsRepo) {
|
|
57
|
+
if (!content.owner) report.error("content.owner", 'is required for the github content backend (or set the GITHUB_REPO variable, "owner/repo")');
|
|
58
|
+
if (!content.repo) report.error("content.repo", 'is required for the github content backend (or set the GITHUB_REPO variable, "owner/repo")');
|
|
59
|
+
if (!content.branch) report.warn("content.branch", 'not set — defaulting to "main"');
|
|
60
|
+
|
|
61
|
+
const draft = content.draftBranch;
|
|
62
|
+
if (draft !== undefined && (typeof draft !== "string" || !draft)) {
|
|
63
|
+
report.error("content.draftBranch", "must be a non-empty branch name");
|
|
64
|
+
} else if (draft && draft === (content.branch || "main")) {
|
|
65
|
+
report.error("content.draftBranch", "must differ from content.branch — same branch means saving publishes");
|
|
66
|
+
} else if (!draft) {
|
|
67
|
+
report.warn(
|
|
68
|
+
"content.draftBranch",
|
|
69
|
+
"not set — on this backend saving commits straight to the deploy branch (saving IS publishing)",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (provider === "fs" && runtime !== "worker") {
|
|
75
|
+
if (!content.publishBranch) report.warn("content.publishBranch", "not set — Publish will fail");
|
|
76
|
+
if (!Array.isArray(content.publishPaths) || content.publishPaths.length === 0) {
|
|
77
|
+
report.warn("content.publishPaths", "not set — Publish has nothing to stage");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (content.list && !isPlainObject(content.list)) {
|
|
82
|
+
report.error("content.list", "must be an object");
|
|
83
|
+
}
|
|
84
|
+
if (content.commitMessage !== undefined && typeof content.commitMessage !== "function") {
|
|
85
|
+
report.error("content.commitMessage", "must be a function (timestamp) => string");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkAuth(config, report, { getSecret }) {
|
|
90
|
+
const auth = config.auth;
|
|
91
|
+
if (!isPlainObject(auth)) {
|
|
92
|
+
report.error("auth", "is required and must be an object");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const provider = auth.provider ?? "basic";
|
|
97
|
+
if (!AUTH_PROVIDERS.includes(provider)) {
|
|
98
|
+
report.error("auth.provider", `must be one of ${AUTH_PROVIDERS.join(", ")} (got "${provider}")`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (provider === "basic") {
|
|
103
|
+
const users = auth.users;
|
|
104
|
+
if (users !== undefined && !Array.isArray(users)) {
|
|
105
|
+
report.error("auth.users", "must be an array");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(users)) {
|
|
109
|
+
users.forEach((entry, i) => {
|
|
110
|
+
const at = `auth.users[${i}]`;
|
|
111
|
+
if (!isPlainObject(entry)) return report.error(at, "must be an object");
|
|
112
|
+
if (!entry.user) report.error(`${at}.user`, "is required");
|
|
113
|
+
if (!entry.pass && !entry.passEnv) {
|
|
114
|
+
report.error(`${at}`, `user "${entry.user}" needs either pass or passEnv`);
|
|
115
|
+
}
|
|
116
|
+
if (entry.role && !["admin", "editor"].includes(entry.role)) {
|
|
117
|
+
report.warn(`${at}.role`, `unknown role "${entry.role}" — only "admin" grants admin routes`);
|
|
118
|
+
}
|
|
119
|
+
// The failure this whole module exists for.
|
|
120
|
+
if (entry.passEnv && !getSecret(entry.passEnv)) {
|
|
121
|
+
report.warn(
|
|
122
|
+
`${at}.passEnv`,
|
|
123
|
+
`${entry.passEnv} is not set — user "${entry.user}" is DISABLED (check for a typo in the variable name)`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
} else if (!auth.passEnv && !auth.userEnv) {
|
|
128
|
+
report.warn("auth.users", "no users configured — the CMS will run without authentication");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (provider === "github-oauth") {
|
|
133
|
+
if (!auth.jwtSecretEnv) report.error("auth.jwtSecretEnv", "is required for github-oauth");
|
|
134
|
+
else if (!getSecret(auth.jwtSecretEnv)) report.error("auth.jwtSecretEnv", `${auth.jwtSecretEnv} is not set`);
|
|
135
|
+
if (!Array.isArray(auth.allowedLogins) || auth.allowedLogins.length === 0) {
|
|
136
|
+
report.warn("auth.allowedLogins", "empty — any GitHub account can sign in");
|
|
137
|
+
}
|
|
138
|
+
if (!auth.roles && !auth.defaultRole) {
|
|
139
|
+
report.warn("auth.defaultRole", 'not set — allowed logins get "editor"');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (provider === "cloudflare-access") {
|
|
144
|
+
if (!auth.teamDomain) report.error("auth.teamDomain", "is required for cloudflare-access");
|
|
145
|
+
if (!auth.audience) report.error("auth.audience", "is required for cloudflare-access");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (auth.jwtSecretEnv && !getSecret(auth.jwtSecretEnv)) {
|
|
149
|
+
report.warn("auth.jwtSecretEnv", `${auth.jwtSecretEnv} is not set — media tokens cannot be issued`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function checkCollections(config, report) {
|
|
154
|
+
const collections = config.collections;
|
|
155
|
+
if (!isPlainObject(collections)) {
|
|
156
|
+
report.error("collections", "is required and must be an object");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (Object.keys(collections).length === 0) {
|
|
160
|
+
report.warn("collections", "is empty — the admin UI will have nothing to edit");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [name, col] of Object.entries(collections)) {
|
|
164
|
+
const at = `collections.${name}`;
|
|
165
|
+
if (!isPlainObject(col)) {
|
|
166
|
+
report.error(at, "must be an object");
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!col.label) report.warn(`${at}.label`, "not set — the admin UI will show the raw name");
|
|
170
|
+
|
|
171
|
+
for (const key of ["listFields", "filters", "metaFields"]) {
|
|
172
|
+
if (col[key] === undefined) continue;
|
|
173
|
+
if (!Array.isArray(col[key])) {
|
|
174
|
+
report.error(`${at}.${key}`, "must be an array");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
col[key].forEach((field, i) => {
|
|
178
|
+
if (!isPlainObject(field) || !field.key) {
|
|
179
|
+
report.error(`${at}.${key}[${i}]`, "must be an object with a `key`");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// `sort` is a spec — `{ field, direction }`. An array shape also exists in
|
|
185
|
+
// older configs; the index builder tolerates it, but the API only sorts by
|
|
186
|
+
// the object form, so an array silently does nothing. Say so.
|
|
187
|
+
if (col.sort !== undefined) {
|
|
188
|
+
if (Array.isArray(col.sort)) {
|
|
189
|
+
report.warn(
|
|
190
|
+
`${at}.sort`,
|
|
191
|
+
"is an array — the list API only applies { field, direction }, so this sort is never used",
|
|
192
|
+
);
|
|
193
|
+
} else if (!isPlainObject(col.sort)) {
|
|
194
|
+
report.error(`${at}.sort`, "must be an object like { field, direction }");
|
|
195
|
+
} else if (typeof col.sort.field !== "string" || !col.sort.field) {
|
|
196
|
+
report.error(`${at}.sort.field`, "is required and must be a string");
|
|
197
|
+
} else if (col.sort.direction && !["asc", "desc"].includes(col.sort.direction)) {
|
|
198
|
+
report.error(`${at}.sort.direction`, 'must be "asc" or "desc"');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A relation field points at one or more other collections; a typo there
|
|
203
|
+
// silently stops names from resolving in list views. `collection` may name
|
|
204
|
+
// a single target or a list of them — an array reported as one name is how
|
|
205
|
+
// this check first failed a valid multi-target field.
|
|
206
|
+
for (const field of col.metaFields || []) {
|
|
207
|
+
if (!field?.collection) continue;
|
|
208
|
+
const targets = Array.isArray(field.collection) ? field.collection : [field.collection];
|
|
209
|
+
if (Array.isArray(field.collection) && targets.length === 0) {
|
|
210
|
+
report.error(`${at}.metaFields (${field.key})`, "collection list is empty");
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
for (const target of targets) {
|
|
214
|
+
if (typeof target !== "string" || !target) {
|
|
215
|
+
report.error(
|
|
216
|
+
`${at}.metaFields (${field.key})`,
|
|
217
|
+
`collection targets must be non-empty strings, got ${JSON.stringify(target)}`,
|
|
218
|
+
);
|
|
219
|
+
} else if (!collections[target]) {
|
|
220
|
+
report.error(
|
|
221
|
+
`${at}.metaFields (${field.key})`,
|
|
222
|
+
`references unknown collection "${target}"`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function checkForms(config, report, { getSecret }) {
|
|
231
|
+
const { forms, mail } = config;
|
|
232
|
+
|
|
233
|
+
if (mail !== undefined) {
|
|
234
|
+
if (!isPlainObject(mail)) {
|
|
235
|
+
report.error("mail", "must be an object like { from, keyEnv? }");
|
|
236
|
+
} else {
|
|
237
|
+
if (!mail.from) report.error("mail.from", 'is required — e.g. "Site <forms@site.com>" (domain must be verified at the provider)');
|
|
238
|
+
const keyEnv = mail.keyEnv || "RESEND_API_KEY";
|
|
239
|
+
if (!getSecret(keyEnv)) {
|
|
240
|
+
report.warn("mail.keyEnv", `${keyEnv} is not set — form delivery will answer 503`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (forms === undefined) return;
|
|
246
|
+
if (!isPlainObject(forms)) {
|
|
247
|
+
report.error("forms", "must be an object of formName → definition");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (Object.keys(forms).length && !mail) {
|
|
251
|
+
report.warn("forms", "defined without config.mail — submissions will answer 503 until mail is configured");
|
|
252
|
+
}
|
|
253
|
+
for (const [name, def] of Object.entries(forms)) {
|
|
254
|
+
const at = `forms.${name}`;
|
|
255
|
+
if (!isPlainObject(def)) {
|
|
256
|
+
report.error(at, "must be an object");
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!def.to || typeof def.to !== "string" || !def.to.includes("@")) {
|
|
260
|
+
report.error(`${at}.to`, "is required and must be an email address");
|
|
261
|
+
}
|
|
262
|
+
if (def.subject !== undefined && typeof def.subject !== "string" && typeof def.subject !== "function") {
|
|
263
|
+
report.error(`${at}.subject`, "must be a string or a (fields) => string function");
|
|
264
|
+
}
|
|
265
|
+
if (def.redirect !== undefined && (typeof def.redirect !== "string" || !def.redirect.startsWith("/"))) {
|
|
266
|
+
report.error(`${at}.redirect`, 'must be a site-relative path like "/tesekkurler/"');
|
|
267
|
+
}
|
|
268
|
+
if (def.turnstile) {
|
|
269
|
+
const secretEnv = def.turnstileSecretEnv || "TURNSTILE_SECRET";
|
|
270
|
+
if (!getSecret(secretEnv)) {
|
|
271
|
+
report.warn(`${at}.turnstile`, `${secretEnv} is not set — verification will answer 503`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function checkMisc(config, report) {
|
|
278
|
+
const { locales, defaultLocale } = config;
|
|
279
|
+
if (locales !== undefined && !Array.isArray(locales)) {
|
|
280
|
+
report.error("locales", "must be an array");
|
|
281
|
+
} else if (Array.isArray(locales) && defaultLocale && !locales.includes(defaultLocale)) {
|
|
282
|
+
report.error("defaultLocale", `"${defaultLocale}" is not in locales [${locales.join(", ")}]`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (config.media !== undefined) {
|
|
286
|
+
if (!isPlainObject(config.media)) report.error("media", "must be an object");
|
|
287
|
+
else if (!config.media.cdnBase) report.error("media.cdnBase", "is required when media is configured");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (config.previewUrl !== undefined && typeof config.previewUrl !== "function") {
|
|
291
|
+
report.error("previewUrl", "must be a function ({ collection, data }) => string");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const corsOrigin = config.cors?.origin ?? "*";
|
|
295
|
+
const list = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
|
|
296
|
+
if (list.includes("*") && config.auth?.provider) {
|
|
297
|
+
report.warn(
|
|
298
|
+
"cors.origin",
|
|
299
|
+
"is '*' — any site can call this API in a logged-in user's browser",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Validate a cms.config object.
|
|
306
|
+
*
|
|
307
|
+
* @param {Object} config
|
|
308
|
+
* @param {Object} [opts]
|
|
309
|
+
* @param {"node"|"worker"} [opts.runtime="node"]
|
|
310
|
+
* @param {(name: string) => string|undefined} [opts.getSecret] Secret lookup;
|
|
311
|
+
* defaults to process.env so env-dependent checks work on Node.
|
|
312
|
+
* @returns {{ errors: {path: string, message: string}[],
|
|
313
|
+
* warnings: {path: string, message: string}[] }}
|
|
314
|
+
*/
|
|
315
|
+
export function validateConfig(config, { runtime = "node", getSecret } = {}) {
|
|
316
|
+
const report = new Report();
|
|
317
|
+
const lookup = getSecret ?? ((name) => globalThis.process?.env?.[name]);
|
|
318
|
+
|
|
319
|
+
if (!isPlainObject(config)) {
|
|
320
|
+
report.error("config", "must be an object — did cms.config.mjs export a default?");
|
|
321
|
+
return report;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
checkContent(config, report, { runtime });
|
|
325
|
+
checkAuth(config, report, { getSecret: lookup });
|
|
326
|
+
checkCollections(config, report);
|
|
327
|
+
checkForms(config, report, { getSecret: lookup });
|
|
328
|
+
checkMisc(config, report);
|
|
329
|
+
|
|
330
|
+
return { errors: report.errors, warnings: report.warnings };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function format(entries) {
|
|
334
|
+
return entries.map(({ path, message }) => ` ${path} ${message}`).join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Validate and refuse to continue on error. Warnings are printed, not fatal.
|
|
339
|
+
*
|
|
340
|
+
* @throws {Error} with every problem listed, not just the first.
|
|
341
|
+
*/
|
|
342
|
+
export function assertValidConfig(config, opts = {}) {
|
|
343
|
+
const { errors, warnings } = validateConfig(config, opts);
|
|
344
|
+
if (warnings.length) {
|
|
345
|
+
console.warn(`cms.config warnings:\n${format(warnings)}`);
|
|
346
|
+
}
|
|
347
|
+
if (errors.length) {
|
|
348
|
+
throw new Error(`Invalid cms.config (${errors.length} problem${errors.length > 1 ? "s" : ""}):\n${format(errors)}`);
|
|
349
|
+
}
|
|
350
|
+
return { warnings };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Human-readable summary of what the config actually resolved to.
|
|
355
|
+
*
|
|
356
|
+
* Printed at startup so a misconfiguration is visible in the first lines of
|
|
357
|
+
* the log instead of being discovered later as odd behaviour.
|
|
358
|
+
*
|
|
359
|
+
* @param {Object} config
|
|
360
|
+
* @param {Object} [opts]
|
|
361
|
+
* @param {Object} [opts.adapters] Resolved adapters, for `configured` flags.
|
|
362
|
+
* @param {(name: string) => string|undefined} [opts.getSecret]
|
|
363
|
+
* @param {"node"|"worker"} [opts.runtime]
|
|
364
|
+
* @returns {string[]} lines
|
|
365
|
+
*/
|
|
366
|
+
export function describeConfig(config, { adapters, getSecret, runtime = "node" } = {}) {
|
|
367
|
+
const lookup = getSecret ?? ((name) => globalThis.process?.env?.[name]);
|
|
368
|
+
const lines = [];
|
|
369
|
+
|
|
370
|
+
const authProvider = config.auth?.provider ?? "basic";
|
|
371
|
+
if (authProvider === "basic" && Array.isArray(config.auth?.users)) {
|
|
372
|
+
const active = [];
|
|
373
|
+
const disabled = [];
|
|
374
|
+
for (const entry of config.auth.users) {
|
|
375
|
+
const pass = entry.passEnv ? lookup(entry.passEnv) : entry.pass;
|
|
376
|
+
(pass ? active : disabled).push(entry);
|
|
377
|
+
}
|
|
378
|
+
let line = `auth basic — ${active.length}/${config.auth.users.length} users active`;
|
|
379
|
+
if (active.length) line += ` (${active.map((u) => `${u.user}:${u.role || "editor"}`).join(", ")})`;
|
|
380
|
+
lines.push(line);
|
|
381
|
+
if (disabled.length) {
|
|
382
|
+
lines.push(
|
|
383
|
+
` DISABLED: ${disabled.map((u) => `${u.user} (${u.passEnv || "no password"})`).join(", ")}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
lines.push(`auth ${authProvider}${adapters?.auth?.configured === false ? " — NOT CONFIGURED (open access)" : ""}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const provider = runtime === "worker" ? "github" : (config.content?.provider ?? "fs");
|
|
391
|
+
const target =
|
|
392
|
+
provider === "github"
|
|
393
|
+
? `${config.content?.owner}/${config.content?.repo}@${config.content?.branch || "main"}`
|
|
394
|
+
: `publish → ${config.content?.publishBranch || "(unset)"}`;
|
|
395
|
+
lines.push(`content ${provider} — ${config.content?.pagesDir} (${target})`);
|
|
396
|
+
|
|
397
|
+
if (config.media?.cdnBase) {
|
|
398
|
+
lines.push(`media ${config.media.cdnBase}${config.media.tenantId ? ` (tenant: ${config.media.tenantId})` : ""}`);
|
|
399
|
+
}
|
|
400
|
+
if (adapters?.build) {
|
|
401
|
+
lines.push(`build netlify — ${adapters.build.configured ? "configured" : "not configured"}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const collections = Object.keys(config.collections || {});
|
|
405
|
+
lines.push(`content model ${collections.length} collections: ${collections.join(", ")}`);
|
|
406
|
+
|
|
407
|
+
return lines;
|
|
408
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form submission helpers — parsing, abuse damping, message assembly.
|
|
3
|
+
*
|
|
4
|
+
* The route in routes.mjs is the policy; these are the mechanics. Kept apart
|
|
5
|
+
* so the Worker runtime can reuse the exact same pieces with its own rate
|
|
6
|
+
* limiter, and so every rule here is unit-testable without a Request cycle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Hard ceilings, not configuration: a contact form that legitimately needs
|
|
10
|
+
// more than this is a different feature. Oversize is a 400, not a truncation.
|
|
11
|
+
export const FORM_LIMITS = { maxFields: 30, maxFieldLength: 5000, maxTotalLength: 20_000 };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read submitted fields from a Request: JSON or form-encoded (urlencoded and
|
|
15
|
+
* multipart both arrive via formData()). File parts are dropped — this module
|
|
16
|
+
* delivers text messages, not attachments.
|
|
17
|
+
*
|
|
18
|
+
* @returns {Promise<Record<string, string> | null>} null on an unreadable body.
|
|
19
|
+
*/
|
|
20
|
+
export async function readFormFields(request) {
|
|
21
|
+
const type = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
22
|
+
try {
|
|
23
|
+
if (type.includes("application/json")) {
|
|
24
|
+
const data = await request.json();
|
|
25
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
|
26
|
+
const fields = {};
|
|
27
|
+
for (const [key, value] of Object.entries(data)) {
|
|
28
|
+
if (typeof value === "string" || typeof value === "number") fields[key] = String(value);
|
|
29
|
+
}
|
|
30
|
+
return fields;
|
|
31
|
+
}
|
|
32
|
+
const data = await request.formData();
|
|
33
|
+
const fields = {};
|
|
34
|
+
for (const [key, value] of data.entries()) {
|
|
35
|
+
if (typeof value === "string") fields[key] = value;
|
|
36
|
+
}
|
|
37
|
+
return fields;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** First violated limit as a message, or null when the submission fits. */
|
|
44
|
+
export function fieldsError(fields) {
|
|
45
|
+
const entries = Object.entries(fields);
|
|
46
|
+
if (entries.length > FORM_LIMITS.maxFields) return `Too many fields (max ${FORM_LIMITS.maxFields})`;
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const [key, value] of entries) {
|
|
49
|
+
if (value.length > FORM_LIMITS.maxFieldLength) {
|
|
50
|
+
return `Field "${key}" is too long (max ${FORM_LIMITS.maxFieldLength} characters)`;
|
|
51
|
+
}
|
|
52
|
+
total += key.length + value.length;
|
|
53
|
+
}
|
|
54
|
+
if (total > FORM_LIMITS.maxTotalLength) return `Submission too large (max ${FORM_LIMITS.maxTotalLength} characters)`;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Plain-text message body: one `key: value` line per real field. */
|
|
59
|
+
export function formatMessage(fields, { skip = [] } = {}) {
|
|
60
|
+
const hidden = new Set([...skip, "form-name", "cf-turnstile-response"]);
|
|
61
|
+
return Object.entries(fields)
|
|
62
|
+
.filter(([key]) => !hidden.has(key))
|
|
63
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
64
|
+
.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fixed-window in-memory rate limiter for the Node runtime. Per-process by
|
|
69
|
+
* design — the Node server is a single process, and forms are a trickle.
|
|
70
|
+
* The Worker runtime uses the KV-backed limiter in @stelstone/worker.
|
|
71
|
+
*/
|
|
72
|
+
export function createMemoryRateLimiter({ limit = 5, windowMs = 60_000 } = {}) {
|
|
73
|
+
const windows = new Map(); // key → { count, resetAt }
|
|
74
|
+
return {
|
|
75
|
+
async allow(key) {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const entry = windows.get(key);
|
|
78
|
+
if (!entry || entry.resetAt <= now) {
|
|
79
|
+
windows.set(key, { count: 1, resetAt: now + windowMs });
|
|
80
|
+
// Opportunistic cleanup so the map does not grow with dead keys.
|
|
81
|
+
if (windows.size > 1000) {
|
|
82
|
+
for (const [k, v] of windows) if (v.resetAt <= now) windows.delete(k);
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
entry.count += 1;
|
|
87
|
+
return entry.count <= limit;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Client IP for rate-limit keys: Cloudflare's header, then the proxy's. */
|
|
93
|
+
export function clientIp(request) {
|
|
94
|
+
return (
|
|
95
|
+
request.headers.get("cf-connecting-ip") ??
|
|
96
|
+
(request.headers.get("x-forwarded-for") ?? "").split(",")[0].trim() ??
|
|
97
|
+
"unknown"
|
|
98
|
+
) || "unknown";
|
|
99
|
+
}
|