@selvajs/cli 4.14.0 → 4.14.2

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": "@selvajs/cli",
3
- "version": "4.14.0",
3
+ "version": "4.14.2",
4
4
  "description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -232,6 +232,60 @@ export function checkBodySizeLimit(env) {
232
232
  return green(`BODY_SIZE_LIMIT=${raw}`);
233
233
  }
234
234
 
235
+ /**
236
+ * `ADDRESS_HEADER` / `XFF_DEPTH` are read by adapter-node, not by Selva, and
237
+ * unset they are invisible: nothing fails, nothing logs, and the deployment
238
+ * looks healthy.
239
+ *
240
+ * What actually happens is that `getClientAddress()` returns the socket peer,
241
+ * which behind a reverse proxy is the proxy — `127.0.0.1` for every request
242
+ * from every user. Login rate limiting keys on that, so the whole instance
243
+ * shares one bucket: five failed logins from anywhere return 429 to everyone,
244
+ * and only a *successful* login clears a bucket, which nobody can then reach.
245
+ *
246
+ * `ORIGIN` is the proxy tell. It is only needed when the app is served under a
247
+ * public URL it can't derive from its own socket, so its presence means a proxy
248
+ * is terminating requests. Without it we say nothing: on a directly-reachable
249
+ * app these settings are a footgun, since `X-Forwarded-For` is client-supplied
250
+ * and anyone could then pick their own rate-limit bucket.
251
+ */
252
+ export function checkClientAddress(env) {
253
+ const header = env.ADDRESS_HEADER;
254
+ const depth = env.XFF_DEPTH;
255
+
256
+ if (!env.ORIGIN) {
257
+ // No proxy signal — nothing to advise either way.
258
+ return green('ADDRESS_HEADER not needed (no ORIGIN, so no reverse proxy)');
259
+ }
260
+
261
+ if (!header) {
262
+ return red(
263
+ `ADDRESS_HEADER unset while ORIGIN is set — every request looks like it came from ` +
264
+ `the proxy, so all users share ONE login rate-limit bucket. Five failed logins ` +
265
+ `from anyone locks out the whole instance for 15 minutes. ` +
266
+ `Set ADDRESS_HEADER=X-Forwarded-For and XFF_DEPTH=<number of proxies>.`
267
+ );
268
+ }
269
+
270
+ if (header.toLowerCase() === 'x-forwarded-for' && !depth) {
271
+ return yellow(
272
+ `ADDRESS_HEADER=${header} but XFF_DEPTH is unset. X-Forwarded-For is a ` +
273
+ `client-appendable list, so a caller can prepend fake entries — adapter-node ` +
274
+ `needs the proxy count to know which entry is real. Set XFF_DEPTH=1 for a ` +
275
+ `single reverse proxy, 2 if a CDN or load balancer sits in front of it.`
276
+ );
277
+ }
278
+
279
+ if (depth !== undefined && !/^[1-9][0-9]*$/.test(depth)) {
280
+ return red(
281
+ `XFF_DEPTH="${depth}" is not a positive integer — it counts proxies from the ` +
282
+ `outside in. Use 1 for a single reverse proxy.`
283
+ );
284
+ }
285
+
286
+ return green(`ADDRESS_HEADER=${header}${depth ? ` (XFF_DEPTH=${depth})` : ''}`);
287
+ }
288
+
235
289
  // A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
236
290
  // encodes a value in the new name, so migrate leaves it alone rather than
237
291
  // guessing — it's reported here and nowhere else.
@@ -127,9 +127,14 @@ function nextSteps({ rawDir, values, supabase }) {
127
127
  if (supabase) {
128
128
  lines.push(
129
129
  ` ${n++}. Apply the database schema (once per Supabase project):`,
130
+ pc.dim(' npx supabase login # or: export SUPABASE_ACCESS_TOKEN=sbp_...'),
130
131
  pc.dim(' npx selva-supabase # copy migrations into ./supabase'),
131
132
  pc.dim(' npx supabase link --project-ref <ref>'),
132
- pc.dim(' npx supabase db push')
133
+ pc.dim(' npx supabase db push'),
134
+ // `link` fails without an ACCOUNT credential, which is none of the three
135
+ // project keys the prompts just collected — the distinction is the most
136
+ // common first-run stall, so name it here rather than only in the docs.
137
+ pc.dim(' (login is a Supabase account credential — not the keys in .env)')
133
138
  );
134
139
  }
135
140
 
@@ -28,6 +28,7 @@ import { checkBootPersistence } from '../checks/boot.js';
28
28
  import { checkRuntimeEnvironment } from '../checks/runtime.js';
29
29
  import {
30
30
  checkBodySizeLimit,
31
+ checkClientAddress,
31
32
  checkDeprecatedEnv,
32
33
  checkEnvDocumentation,
33
34
  checkHeaderNames,
@@ -84,6 +85,7 @@ export async function runDoctor(argv = []) {
84
85
  checks.push(...checkBootPersistence(dir));
85
86
  checks.push(checkOrigin(env));
86
87
  checks.push(checkBodySizeLimit(env));
88
+ checks.push(checkClientAddress(env));
87
89
  checks.push(...checkDeprecatedEnv(env));
88
90
  checks.push(checkEnvDocs(dir));
89
91
 
@@ -265,7 +267,7 @@ async function checkSupabaseMigrations(dir, env) {
265
267
  clearTimeout(timer);
266
268
  if (res.status === 404) {
267
269
  return red(
268
- `Database is missing selva.migration_head() — migrations are pending (expected head ${expected}). Sync + run: npx supabase db push`
270
+ `Database is missing selva.migration_head() — migrations are pending (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
269
271
  );
270
272
  }
271
273
  if (!res.ok) {
@@ -274,13 +276,13 @@ async function checkSupabaseMigrations(dir, env) {
274
276
  const actual = String((await res.json()) ?? '');
275
277
  if (!actual) {
276
278
  return red(
277
- `Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
279
+ `Database reports no applied migrations (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
278
280
  );
279
281
  }
280
282
  // String comparison — relies on both heads being fixed-width, zero-padded timestamps.
281
283
  if (actual < expected) {
282
284
  return red(
283
- `Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
285
+ `Database migration head ${actual} is behind the installed provider (${expected}). Run: npx selva-supabase && npx supabase db push`
284
286
  );
285
287
  }
286
288
  return green(`Database migration head ${actual} matches the installed provider`);
@@ -128,6 +128,22 @@ export async function runSetupProxy(argv = []) {
128
128
  return;
129
129
  }
130
130
 
131
+ // Caddy sets X-Forwarded-For on its own, but the app ignores it unless told
132
+ // to trust it — and unset, every request looks like it came from Caddy, so
133
+ // all users share one login rate-limit bucket. Nothing fails visibly, which
134
+ // is why it is said here rather than left to the docs.
135
+ if (!env.ADDRESS_HEADER) {
136
+ p.log.warn(
137
+ `Add these to .env, then \`selva restart\`:\n` +
138
+ pc.cyan(' ADDRESS_HEADER=X-Forwarded-For\n XFF_DEPTH=1\n') +
139
+ pc.dim(
140
+ 'Without them the app sees every request as coming from Caddy, so one\n' +
141
+ 'user failing five logins rate-limits everyone. Use XFF_DEPTH=2 if a CDN\n' +
142
+ 'or load balancer sits in front of Caddy.'
143
+ )
144
+ );
145
+ }
146
+
131
147
  p.outro(
132
148
  [
133
149
  pc.green(`Caddy is serving ${domain}.`),
@@ -71,7 +71,7 @@ const DEPENDENCIES = {
71
71
  * without this. Its migration SQL is not: `@selvajs/selva` publishes only
72
72
  * `build` + `templates`, while the SQL and the `selva-supabase` bin ship in
73
73
  * this package's own tarball. Without it, a deployment has no way to reach the
74
- * schema it needs — `npx selva-supabase sync-migrations` resolves nothing and
74
+ * schema it needs — `npx selva-supabase` resolves nothing and
75
75
  * `doctor`'s migration-head check finds no directory to compare against.
76
76
  */
77
77
  export const SUPABASE_DEPENDENCIES = {