@webjsdev/server 0.8.9 → 0.8.11

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,250 @@
1
+ /**
2
+ * Startup env-var validation with a typed schema hook (issue #236).
3
+ *
4
+ * webjs auto-loads `<appDir>/.env` into `process.env` at boot, but does no
5
+ * validation, so a missing or misconfigured required var (DATABASE_URL,
6
+ * AUTH_SECRET, ...) fails late and cryptically (a Prisma connect error
7
+ * mid-request, an undefined secret signing a token). This module adds an
8
+ * optional boot-time validation hook that fails fast with one clear message
9
+ * listing EVERY missing or invalid var at once, before the app serves a
10
+ * request.
11
+ *
12
+ * The hook is an optional `env.{js,ts}` module at the app root (sibling of
13
+ * `middleware.js` / `readiness.js`), default-exporting either:
14
+ * 1. a plain SCHEMA object, dependency-free, e.g.
15
+ * export default {
16
+ * DATABASE_URL: 'string',
17
+ * AUTH_SECRET: { type: 'string', required: true, minLength: 16 },
18
+ * PORT: { type: 'number', optional: true, default: 3000 },
19
+ * NODE_ENV: { type: 'enum', values: ['development','production','test'] },
20
+ * };
21
+ * 2. a FUNCTION `(env) => void | throw` for full custom validation (the
22
+ * escape hatch), so an app can use zod or anything it likes without webjs
23
+ * depending on it. A thrown Error is surfaced as the boot failure.
24
+ *
25
+ * The validator is a PURE function (`validateEnv`) so it unit-tests with an
26
+ * injected schema + env object, no temp app dir needed. `loadEnvSchema` reads
27
+ * the app's `env.{js,ts}` (returns `null` when absent, so the feature is fully
28
+ * opt-in), and `applyEnvValidation` is the side-effecting boot wrapper: it runs
29
+ * the schema/function against `process.env`, applies coerced + defaulted values
30
+ * back into `process.env`, and throws a clear aggregated Error on failure (so
31
+ * `createRequestHandler` rejects and the CLI exits non-zero, consistent with
32
+ * the Node-version preflight).
33
+ */
34
+ import { join } from 'node:path';
35
+ import { pathToFileURL } from 'node:url';
36
+ import { stat } from 'node:fs/promises';
37
+
38
+ /** Field type names a schema may declare. */
39
+ const KNOWN_TYPES = new Set(['string', 'number', 'boolean', 'url', 'enum']);
40
+
41
+ /**
42
+ * Normalize a schema field to its object form. A bare string like `'string'`
43
+ * is shorthand for `{ type: 'string' }`.
44
+ * @param {string | object} rule
45
+ * @returns {{ type: string, required?: boolean, optional?: boolean, default?: any, values?: any[], minLength?: number, pattern?: (string|RegExp) }}
46
+ */
47
+ function normalizeRule(rule) {
48
+ if (typeof rule === 'string') return { type: rule };
49
+ return rule && typeof rule === 'object' ? rule : { type: 'string' };
50
+ }
51
+
52
+ /**
53
+ * Is a field required? A field is required by default. It is optional when it
54
+ * declares `optional: true`, `required: false`, or carries a `default`.
55
+ * @param {object} rule normalized rule
56
+ */
57
+ function isRequired(rule) {
58
+ if (rule.required === false) return false;
59
+ if (rule.optional === true) return false;
60
+ if ('default' in rule) return false;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Coerce a raw string value to the declared type, enforcing the field's
66
+ * constraints. Returns `{ value }` on success or `{ error }` (a human string)
67
+ * on failure.
68
+ * @param {string} name the env var name (for messages)
69
+ * @param {object} rule normalized rule
70
+ * @param {string} raw the raw string from the env
71
+ * @returns {{ value: any } | { error: string }}
72
+ */
73
+ function coerce(name, rule, raw) {
74
+ const type = rule.type || 'string';
75
+ switch (type) {
76
+ case 'string': {
77
+ if (typeof rule.minLength === 'number' && raw.length < rule.minLength) {
78
+ return { error: `${name} must be at least ${rule.minLength} characters (got ${raw.length})` };
79
+ }
80
+ if (rule.pattern != null) {
81
+ const re = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern);
82
+ if (!re.test(raw)) return { error: `${name} does not match required pattern ${re}` };
83
+ }
84
+ return { value: raw };
85
+ }
86
+ case 'number': {
87
+ const n = Number(raw);
88
+ if (raw.trim() === '' || Number.isNaN(n)) {
89
+ // Never echo the value: a secret given the wrong type would leak to logs.
90
+ return { error: `${name} must be a number` };
91
+ }
92
+ return { value: n };
93
+ }
94
+ case 'boolean': {
95
+ const v = raw.trim().toLowerCase();
96
+ if (['1', 'true', 'yes', 'on'].includes(v)) return { value: true };
97
+ if (['0', 'false', 'no', 'off'].includes(v)) return { value: false };
98
+ // Never echo the value: list the accepted spellings, not what was given.
99
+ return { error: `${name} must be a boolean (one of true/false/1/0/yes/no/on/off)` };
100
+ }
101
+ case 'url': {
102
+ try {
103
+ // eslint-disable-next-line no-new
104
+ new URL(raw);
105
+ return { value: raw };
106
+ } catch {
107
+ // Never echo the value: a DSN with embedded credentials must not leak.
108
+ return { error: `${name} must be a valid URL` };
109
+ }
110
+ }
111
+ case 'enum': {
112
+ const values = Array.isArray(rule.values) ? rule.values : [];
113
+ if (!values.includes(raw)) {
114
+ // Name the ALLOWED values (from the schema, safe), never the provided one.
115
+ return { error: `${name} must be one of ${values.map((v) => JSON.stringify(v)).join(', ')}` };
116
+ }
117
+ return { value: raw };
118
+ }
119
+ default:
120
+ return { error: `${name} has an unknown schema type ${JSON.stringify(type)}` };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Pure env validator. Validates `env` against `schema`, collecting ALL errors
126
+ * (never stopping at the first), and computes the coerced + defaulted values to
127
+ * write back. Does NOT mutate `env` or `process.env`; the caller applies
128
+ * `coerced` to `process.env`.
129
+ *
130
+ * When `schema` is a FUNCTION it is the custom-validator escape hatch: it is
131
+ * called with the env object and any thrown Error is surfaced as a single
132
+ * error. A function validator never coerces.
133
+ *
134
+ * @param {object | Function} schema the default export of `env.{js,ts}`
135
+ * @param {Record<string, string|undefined>} env the source env (e.g. process.env)
136
+ * @returns {{ ok: boolean, errors: string[], coerced: Record<string, string> }}
137
+ */
138
+ export function validateEnv(schema, env) {
139
+ // Escape hatch: a function gets the env and validates however it wants.
140
+ if (typeof schema === 'function') {
141
+ try {
142
+ schema(env);
143
+ return { ok: true, errors: [], coerced: {} };
144
+ } catch (e) {
145
+ const msg = e && e.message ? String(e.message) : String(e);
146
+ return { ok: false, errors: [msg], coerced: {} };
147
+ }
148
+ }
149
+
150
+ if (!schema || typeof schema !== 'object') {
151
+ // Nothing to validate against. Treat as a no-op rather than an error so a
152
+ // stray default export does not brick boot.
153
+ return { ok: true, errors: [], coerced: {} };
154
+ }
155
+
156
+ const errors = [];
157
+ /** @type {Record<string, string>} */
158
+ const coerced = {};
159
+
160
+ for (const name of Object.keys(schema)) {
161
+ const rule = normalizeRule(schema[name]);
162
+ const type = rule.type || 'string';
163
+ if (!KNOWN_TYPES.has(type)) {
164
+ errors.push(`${name} declares an unknown type ${JSON.stringify(type)} (expected one of ${[...KNOWN_TYPES].join(', ')})`);
165
+ continue;
166
+ }
167
+ const present = env[name] != null && env[name] !== '';
168
+ if (!present) {
169
+ if (isRequired(rule)) {
170
+ errors.push(`${name} is required but missing`);
171
+ } else if ('default' in rule) {
172
+ // Apply the default, coercing it through the same path so a number
173
+ // default lands as a string in process.env (env values are strings).
174
+ coerced[name] = String(rule.default);
175
+ }
176
+ continue;
177
+ }
178
+ const result = coerce(name, rule, String(env[name]));
179
+ if ('error' in result) {
180
+ errors.push(result.error);
181
+ } else if (typeof result.value !== 'string') {
182
+ // Re-stringify coerced non-string values so process.env stays string-typed.
183
+ coerced[name] = String(result.value);
184
+ }
185
+ }
186
+
187
+ return { ok: errors.length === 0, errors, coerced };
188
+ }
189
+
190
+ /**
191
+ * Compose the aggregated, actionable failure message from a list of errors.
192
+ * @param {string[]} errors
193
+ * @returns {string}
194
+ */
195
+ export function formatEnvErrors(errors) {
196
+ const lines = errors.map((e) => ` - ${e}`);
197
+ return (
198
+ `webjs env validation failed (${errors.length} ${errors.length === 1 ? 'error' : 'errors'}):\n` +
199
+ lines.join('\n') +
200
+ `\n\nFix the variables above in your .env (or the host environment) and restart. ` +
201
+ `The schema lives in env.{js,ts} at the app root.`
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Load the optional `env.{js,ts}` schema module from the app root. Returns the
207
+ * default export (a schema object or a validator function), or `null` when no
208
+ * such file exists, so env validation is fully opt-in.
209
+ * @param {string} appDir
210
+ * @param {{ dev?: boolean }} [opts]
211
+ * @returns {Promise<object | Function | null>}
212
+ */
213
+ export async function loadEnvSchema(appDir, opts = {}) {
214
+ let file = null;
215
+ for (const name of ['env.ts', 'env.js', 'env.mts', 'env.mjs']) {
216
+ const p = join(appDir, name);
217
+ try {
218
+ await stat(p);
219
+ file = p;
220
+ break;
221
+ } catch {
222
+ // not this name, try the next
223
+ }
224
+ }
225
+ if (!file) return null;
226
+ const url = pathToFileURL(file).toString();
227
+ const bust = opts.dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
228
+ const mod = await import(url + bust);
229
+ return mod.default ?? null;
230
+ }
231
+
232
+ /**
233
+ * Side-effecting boot wrapper: load the app's `env.{js,ts}` (if any), validate
234
+ * `process.env` against it, apply coerced + defaulted values back into
235
+ * `process.env`, and THROW a clear aggregated Error on failure. A no-op when
236
+ * `env.{js,ts}` is absent. Called by `createRequestHandler` right after the
237
+ * `.env` auto-load and before any server-only module is imported.
238
+ * @param {string} appDir
239
+ * @param {{ dev?: boolean, env?: Record<string, string|undefined> }} [opts]
240
+ * @returns {Promise<void>}
241
+ */
242
+ export async function applyEnvValidation(appDir, opts = {}) {
243
+ const schema = await loadEnvSchema(appDir, opts);
244
+ if (schema == null) return; // opt-in: no env.{js,ts}, nothing to do
245
+ const env = opts.env ?? process.env;
246
+ const { ok, errors, coerced } = validateEnv(schema, env);
247
+ if (!ok) throw new Error(formatEnvErrors(errors));
248
+ // Apply coerced values + defaults back so the app reads the coerced value.
249
+ for (const key of Object.keys(coerced)) env[key] = coerced[key];
250
+ }
package/src/headers.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Secure-by-default response headers plus a small declarative per-path
3
+ * header config (issue #232).
4
+ *
5
+ * webjs sets a baseline of standard security headers on every document
6
+ * and asset response, so a scaffolded app is not clickjackable or
7
+ * MIME-sniffable out of the box (no reverse proxy required for the
8
+ * baseline). The defaults are LITERAL HTTP headers, no abstraction.
9
+ *
10
+ * An app can ADD, OVERRIDE, or DISABLE any header per path via a
11
+ * declarative config (`package.json` -> `webjs.headers`), shaped like
12
+ * Next's: an array of `{ source, headers: [{ key, value }] }` where
13
+ * `source` is a path pattern matched with the native URLPattern API.
14
+ *
15
+ * Precedence, lowest to highest:
16
+ * 1. secure defaults
17
+ * 2. path config (webjs.headers) overrides/adds/removes a default
18
+ * 3. app middleware (already on the Response when we merge)
19
+ *
20
+ * In other words middleware wins over the path config, which wins over
21
+ * the defaults. We only ever ADD a header the response does not already
22
+ * carry, so a header an app set (in middleware, a route handler, or via
23
+ * `expose`) is never clobbered. A path-config entry with a null/empty
24
+ * value REMOVES a header (e.g. drop a default on a public embed route).
25
+ *
26
+ * This is the connective tissue #233 (CSP) and #234 (CORS) plug into
27
+ * later: the merge seam (applySecurityHeaders) is the single place a
28
+ * future per-path policy layers onto a Response, so neither needs to
29
+ * touch the response pipeline again.
30
+ */
31
+
32
+ /**
33
+ * Baseline security headers, as literal name -> value pairs. Set only
34
+ * when absent, so an app override always wins. HSTS is NOT here: it is
35
+ * conditional (production + HTTPS) and added separately.
36
+ *
37
+ * @type {ReadonlyArray<[string, string]>}
38
+ */
39
+ const SECURE_DEFAULTS = [
40
+ ['X-Content-Type-Options', 'nosniff'],
41
+ ['X-Frame-Options', 'SAMEORIGIN'],
42
+ ['Referrer-Policy', 'strict-origin-when-cross-origin'],
43
+ ['Permissions-Policy', 'camera=(), microphone=(), geolocation=()'],
44
+ ];
45
+
46
+ /** The standard HSTS posture: two years, include subdomains. */
47
+ const HSTS_VALUE = 'max-age=63072000; includeSubDomains';
48
+
49
+ /**
50
+ * Read the per-path header config from the app's package.json
51
+ * (`webjs.headers`). Returns a normalized array of compiled rules, each
52
+ * pairing a URLPattern against the configured header directives. A
53
+ * malformed or absent config yields an empty array (no per-path rules),
54
+ * never a throw: a broken config must not take the app down.
55
+ *
56
+ * Shape consumed:
57
+ * "webjs": { "headers": [ { "source": "/embed/:path*",
58
+ * "headers": [ { "key": "X-Frame-Options", "value": null } ] } ] }
59
+ *
60
+ * A `value` of null, undefined, or false REMOVES the header on a match
61
+ * (the disable-a-default escape hatch). Any other value is stringified
62
+ * and SET (override or add).
63
+ *
64
+ * @param {unknown} pkg parsed package.json (or any object)
65
+ * @returns {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>}
66
+ */
67
+ export function compileHeaderRules(pkg) {
68
+ const raw =
69
+ pkg &&
70
+ typeof pkg === 'object' &&
71
+ /** @type {any} */ (pkg).webjs &&
72
+ /** @type {any} */ (pkg).webjs.headers;
73
+ if (!Array.isArray(raw)) return [];
74
+ /** @type {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>} */
75
+ const rules = [];
76
+ for (const entry of raw) {
77
+ if (!entry || typeof entry !== 'object') continue;
78
+ const source = /** @type {any} */ (entry).source;
79
+ const list = /** @type {any} */ (entry).headers;
80
+ if (typeof source !== 'string' || !Array.isArray(list)) continue;
81
+ let pattern;
82
+ try {
83
+ // Match on the pathname only. A bare path string is the common
84
+ // Next-style usage; URLPattern treats it as the pathname component.
85
+ pattern = new URLPattern({ pathname: source });
86
+ } catch {
87
+ continue; // skip an invalid pattern rather than crash the request
88
+ }
89
+ /** @type {Array<{ key: string, value: string | null }>} */
90
+ const directives = [];
91
+ for (const d of list) {
92
+ if (!d || typeof d !== 'object') continue;
93
+ const key = /** @type {any} */ (d).key;
94
+ if (typeof key !== 'string' || !key) continue;
95
+ const v = /** @type {any} */ (d).value;
96
+ // null / undefined / false means REMOVE the header on a match.
97
+ const value = v === null || v === undefined || v === false ? null : String(v);
98
+ // Validate the key/value against the real Headers parser at COMPILE
99
+ // time (consistent with dropping a bad `source`). A name or value
100
+ // that Headers rejects (an invalid header name, or a value carrying
101
+ // CR/LF) would otherwise make `applySecurityHeaders` THROW on every
102
+ // matching request, a self-inflicted 500, which breaks this file's
103
+ // "a broken config must not take the app down" guarantee. Probe on a
104
+ // throwaway Headers and DROP the directive if it throws. For a delete
105
+ // (value null) only the key needs to be a valid header name, so probe
106
+ // it with a placeholder value.
107
+ try {
108
+ new Headers().set(key, value === null ? 'x' : value);
109
+ } catch {
110
+ // eslint-disable-next-line no-console
111
+ console.warn(`[webjs] dropping invalid webjs.headers directive for key "${key}"`);
112
+ continue;
113
+ }
114
+ directives.push({ key, value });
115
+ }
116
+ if (directives.length) rules.push({ pattern, directives });
117
+ }
118
+ return rules;
119
+ }
120
+
121
+ /**
122
+ * Apply the security defaults and the per-path config to a Response,
123
+ * returning a Response carrying the merged headers. The input Response
124
+ * is not mutated; a new Headers is derived from it so the body/status
125
+ * are preserved by reference (no body copy).
126
+ *
127
+ * Headers already on the response (set by app middleware, a route
128
+ * handler, or `expose`) are treated as authoritative and never
129
+ * overwritten by a default. The per-path config runs AFTER the defaults
130
+ * but BEFORE that "already present" rule is consulted for middleware:
131
+ * the config can override a default freely (it is the app's own
132
+ * declarative intent), while middleware still wins because its headers
133
+ * are on the response before we are even called.
134
+ *
135
+ * @param {Response} res the response produced by the app pipeline
136
+ * @param {{
137
+ * pathname: string,
138
+ * https: boolean,
139
+ * prod: boolean,
140
+ * rules?: Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>,
141
+ * }} ctx
142
+ * @returns {Response}
143
+ */
144
+ export function applySecurityHeaders(res, ctx) {
145
+ const headers = new Headers(res.headers);
146
+ // Snapshot the keys the app already set, so a default never clobbers
147
+ // them. Captured BEFORE we add anything. Lowercased for compare;
148
+ // Headers itself is case-insensitive.
149
+ const appSet = new Set();
150
+ headers.forEach((_v, k) => appSet.add(k.toLowerCase()));
151
+
152
+ // 1. Secure defaults: set only if the app did not already set them.
153
+ for (const [k, v] of SECURE_DEFAULTS) {
154
+ if (!appSet.has(k.toLowerCase())) headers.set(k, v);
155
+ }
156
+ // HSTS only in production AND only over HTTPS (never on a plain-HTTP
157
+ // hop). Same "do not clobber" rule.
158
+ if (ctx.prod && ctx.https && !appSet.has('strict-transport-security')) {
159
+ headers.set('Strict-Transport-Security', HSTS_VALUE);
160
+ }
161
+
162
+ // 2. Per-path config: override / add / remove. Runs over the merged
163
+ // set so it can replace a default this same call just added. It does
164
+ // NOT override a header the app set in middleware (appSet), preserving
165
+ // the middleware-wins precedence.
166
+ const rules = ctx.rules || [];
167
+ for (const rule of rules) {
168
+ if (!rule.pattern.test({ pathname: ctx.pathname })) continue;
169
+ for (const { key, value } of rule.directives) {
170
+ if (appSet.has(key.toLowerCase())) continue; // middleware wins
171
+ // Belt and suspenders: compileHeaderRules already validated the
172
+ // key/value, so this never throws in practice, but a surprise from a
173
+ // directive constructed elsewhere must never throw the response
174
+ // pipeline. Skip the bad directive instead.
175
+ try {
176
+ if (value === null) headers.delete(key);
177
+ else headers.set(key, value);
178
+ } catch {
179
+ /* skip a directive the Headers parser rejects */
180
+ }
181
+ }
182
+ }
183
+
184
+ return new Response(res.body, {
185
+ status: res.status,
186
+ statusText: res.statusText,
187
+ headers,
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Detect whether the original client request was HTTPS, from a web
193
+ * `Request` (the shape `handle()` works with). Honors the same
194
+ * reverse-proxy trust posture as `forwarded.js`: read
195
+ * `X-Forwarded-Proto` only when proxy trust is on
196
+ * (`WEBJS_NO_TRUST_PROXY !== '1'`); otherwise fall back to the request
197
+ * URL's scheme. Never trusts the header blindly.
198
+ *
199
+ * @param {Request} req
200
+ * @returns {boolean}
201
+ */
202
+ export function webRequestIsHttps(req) {
203
+ const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
204
+ if (trust) {
205
+ const proto = req.headers.get('x-forwarded-proto');
206
+ if (proto) return proto.split(',')[0].trim().toLowerCase() === 'https';
207
+ }
208
+ try {
209
+ return new URL(req.url).protocol === 'https:';
210
+ } catch {
211
+ return false;
212
+ }
213
+ }